@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,2553 @@
|
|
|
1
|
+
import { onAssetReload } from '@volter/editor-core/project-asset-refresh';
|
|
2
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Play-mode orchestrator.
|
|
6
|
+
*
|
|
7
|
+
* Editor and game are fully isolated — separate canvas, renderer, and scene.
|
|
8
|
+
* Play mode creates a new game canvas, launches a standalone game session,
|
|
9
|
+
* and tears it all down on stop. The editor scene is never touched.
|
|
10
|
+
*
|
|
11
|
+
* Flow:
|
|
12
|
+
* enterPlayMode → create canvas → createGameRuntime() → game loop
|
|
13
|
+
* exitPlayMode → game stop → remove canvas → re-enable editor
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { installAdapterRuntimeBindings } from '../host/adapter-runtime-bindings';
|
|
17
|
+
import { getAuthoringOverride, setActiveAuthoring } from '@volter/editor-core/authoring/active-adapter';
|
|
18
|
+
import {
|
|
19
|
+
getActiveNetworking,
|
|
20
|
+
inspectedInstanceId,
|
|
21
|
+
setActiveSystems,
|
|
22
|
+
setInspectedInstance,
|
|
23
|
+
updateInstanceSystems,
|
|
24
|
+
} from '@volter/editor-core/authoring/active-systems';
|
|
25
|
+
import { BoundaryAuthoringAdapter } from '@volter/editor-core/authoring/boundary-authoring-adapter';
|
|
26
|
+
import {
|
|
27
|
+
CompositeAuthoringAdapter,
|
|
28
|
+
type CompositeChild,
|
|
29
|
+
} from '@volter/editor-core/authoring/composite-authoring-adapter';
|
|
30
|
+
import { createEphemeralPersistence } from '../host/authoring/ephemeral-persistence';
|
|
31
|
+
import {
|
|
32
|
+
EPHEMERAL_DESTINATION,
|
|
33
|
+
resolvesLiveOnly,
|
|
34
|
+
runWritePipe,
|
|
35
|
+
type WriteAck,
|
|
36
|
+
} from '@volter/editor-core/authoring/write-pipe';
|
|
37
|
+
import { resolveAllRootEntries } from '../host/binding-resolver';
|
|
38
|
+
import type { LogEntry } from '@volter/editor-core/editor-api';
|
|
39
|
+
import { endLogSession, flushLogEntries, startLogSession } from '@volter/editor-core/editor-api';
|
|
40
|
+
import type { ConsoleEntry } from '@volter/editor-core/editor-console';
|
|
41
|
+
import {
|
|
42
|
+
editorConsole,
|
|
43
|
+
formatConsoleArgs,
|
|
44
|
+
resumeEditorConsoleCapture,
|
|
45
|
+
suspendEditorConsoleCapture,
|
|
46
|
+
} from '@volter/editor-core/editor-console';
|
|
47
|
+
import { EDITOR_PARTICIPANT_ID, sendControl } from '@volter/editor-core/editor-presence';
|
|
48
|
+
import {
|
|
49
|
+
isEditorPresentationActive,
|
|
50
|
+
subscribeEditorPresentationActivity,
|
|
51
|
+
} from '@volter/editor-core/editor-presentation-activity';
|
|
52
|
+
import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
|
|
53
|
+
import { GAME_SURFACE_CONTAINMENT_CSS } from '../host/game-realm-page';
|
|
54
|
+
import { reclaimGameRealm } from '../host/game-realm-reclaim';
|
|
55
|
+
import { toolContributionRecording } from '@volter/editor-core/gameplay-sessions';
|
|
56
|
+
import {
|
|
57
|
+
clearGameSurface,
|
|
58
|
+
currentGameRealmMountId,
|
|
59
|
+
installGatedGameGlobals,
|
|
60
|
+
setGameInputGate,
|
|
61
|
+
setGameSurface,
|
|
62
|
+
} from '../host/gated-globals';
|
|
63
|
+
import { hierarchyProjectionFromProjectConfig } from '@volter/editor-core/hierarchy-projection';
|
|
64
|
+
import { type JournalSubject, playJournal } from '../host/history/json-history-resource';
|
|
65
|
+
import { isEditableTarget, setActiveScope } from '@volter/editor-core/hotkeys';
|
|
66
|
+
import { projectBootstrapSettled } from '@volter/editor-core/initial-project';
|
|
67
|
+
import { registerGameNullSubject } from '@volter/editor-core/inspection/game-subject';
|
|
68
|
+
import { fetchGameManifest } from '@volter/editor-core/manifest-project';
|
|
69
|
+
import { registerPerformanceSource } from '@volter/editor-core/performance-sources';
|
|
70
|
+
import {
|
|
71
|
+
beginPlayBoot,
|
|
72
|
+
endPlayBoot,
|
|
73
|
+
markPlayBootPhase,
|
|
74
|
+
type PlayBootPhase,
|
|
75
|
+
} from '@volter/editor-core/play-boot-phase';
|
|
76
|
+
import { presentationSurface } from '@volter/editor-core/presentation-surface';
|
|
77
|
+
import { getCurrentProject } from '@volter/editor-core/project-manager';
|
|
78
|
+
import {
|
|
79
|
+
beginProjectModuleSplitWatch,
|
|
80
|
+
clearProjectModuleSplitReports,
|
|
81
|
+
endProjectModuleSplitWatch,
|
|
82
|
+
formatProjectModuleSplitMessage,
|
|
83
|
+
} from '@volter/editor-core/project-module-split';
|
|
84
|
+
import { clearRootReadiness, recordRootReadiness } from '@volter/editor-core/readiness';
|
|
85
|
+
import { onShellStore } from '@volter/editor-core/shell-store-door';
|
|
86
|
+
import { mountedStoryHasPixiContent } from '@volter/editor-core/stories/pixi-story-model';
|
|
87
|
+
import { domHasRenderableContent, threeSceneHasRenderableContent } from '../host/surface-content';
|
|
88
|
+
import { subscribeSurfaceKeyboard, surfaceHoldsKeyboard } from '@volter/editor-core/surface-keyboard';
|
|
89
|
+
import { publishToolContributionPlay } from '@volter/editor-core/tool-contribution-play';
|
|
90
|
+
import { liveWorldId } from '../host/viewport-root-presentation';
|
|
91
|
+
import {
|
|
92
|
+
cancelPendingWorkspacePlayUtilities,
|
|
93
|
+
revealWorkspacePlayUtilities,
|
|
94
|
+
} from '@volter/editor-core/workspace-play-utilities';
|
|
95
|
+
import { markGameCssScope } from '@volter/editor-sdk/session/game-css-scope';
|
|
96
|
+
import type { EntrypointSelectionOverride } from '@volter/editor-sdk/session/project-module-url';
|
|
97
|
+
import { isEditorLanePath } from '@volter/editor-sdk/session/tool-contribution-convention';
|
|
98
|
+
import { getSeededRandom, type SeededRandom } from '@volter/game-runtime/core/seeded-random';
|
|
99
|
+
import { _engineLogActive } from '@volter/game-runtime/dev/logger';
|
|
100
|
+
import type { PerformanceProfiler } from '@volter/game-runtime/dev/performance-profiler';
|
|
101
|
+
import type { GameSession } from '@volter/game-runtime/runtime/create-runtime';
|
|
102
|
+
import {
|
|
103
|
+
type DebugVirtualInputTarget,
|
|
104
|
+
getDebugRegistry,
|
|
105
|
+
type RunTicksOptions,
|
|
106
|
+
} from '@volter/game-runtime/runtime/debug-registry';
|
|
107
|
+
import type { GameLoop, RootInstance } from '@volter/game-runtime/runtime/game';
|
|
108
|
+
import type { PlaytestContext } from '@volter/game-runtime/runtime/playtest';
|
|
109
|
+
import { runTicksWhenSettled } from '@volter/game-runtime/runtime/run-ticks-settled';
|
|
110
|
+
import {
|
|
111
|
+
type AuthoringAdapter,
|
|
112
|
+
type InspectorProvider,
|
|
113
|
+
nodeKeyedPhysics,
|
|
114
|
+
type TransformProvider,
|
|
115
|
+
} from '@volter/editor-project/adapter';
|
|
116
|
+
import { assertNever } from '@volter/editor-project/adapter/adapter-surface';
|
|
117
|
+
import { declaredRoots, rootById } from '@volter/editor-project/adapter/manifest-interpreter';
|
|
118
|
+
import { readOidSourceAnchors } from '../three/authoring/oid-source-persistence';
|
|
119
|
+
import { oidThree, structuralThree } from '../three/authoring/three-authoring-adapter';
|
|
120
|
+
import type * as THREE from 'three';
|
|
121
|
+
import { deviceEmulatedPixelRatio } from '../game-document/device-preview';
|
|
122
|
+
import { exitDeferredIngestPlay, mountDeferredIngestForPlay } from '../ingest/deferred-ingest-play';
|
|
123
|
+
import { getIngestPlayControl } from '../ingest/ingest-play-control';
|
|
124
|
+
import { withPlayBootStallGuard } from './play-boot-stall';
|
|
125
|
+
import { debugEventsToLogEntries } from './play-log-events';
|
|
126
|
+
import { bindPlayRecordingStop, endPlayRecording } from './play-recording';
|
|
127
|
+
import { createReactPlayAuthoringAdapter } from './react-play-live-authoring';
|
|
128
|
+
|
|
129
|
+
/** Context needed by the orchestrator (passed from the world root's stage). */
|
|
130
|
+
export interface PlayModeContext {
|
|
131
|
+
store: EditorShellStore;
|
|
132
|
+
/** Container for the game canvas (the Game tab panel). */
|
|
133
|
+
gameContainer: HTMLElement;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* THE MOUNTED INSTANCE of this project — the thing that used to be a scatter
|
|
138
|
+
* of module-level `let`s all silently meaning "the one game".
|
|
139
|
+
*
|
|
140
|
+
* This layer knows about mounts, and about nothing a game means by them. An
|
|
141
|
+
* instance is one mount: its own module graph, realm, renderer and session.
|
|
142
|
+
* Two of them is how a multiplayer game gets verified, but equally how you A/B
|
|
143
|
+
* two seeds or watch one scene from two camera rigs — so the vocabulary is the
|
|
144
|
+
* one the rest of the codebase already uses (`active-systems.ts`'s
|
|
145
|
+
* `_byInstance`/`systemsForInstance`), and naming this after any single
|
|
146
|
+
* application of it would hardcode that application into a layer that has no
|
|
147
|
+
* such concept in it.
|
|
148
|
+
*
|
|
149
|
+
* ITS IDENTITY IS THE MOUNT ID, and there is only ever one id for it.
|
|
150
|
+
* `resolveAllRootEntries` opens a mount epoch; every project module of this
|
|
151
|
+
* instance is served under it as `?vgai-mount=<id>`; browser module identity
|
|
152
|
+
* is per-url, so that id IS the module-graph boundary; and `gated-globals.ts`
|
|
153
|
+
* resolves this instance's realm and input gate by reading the same id back
|
|
154
|
+
* off the url. Registering it under any second name would be two identities
|
|
155
|
+
* for one thing, and they would drift.
|
|
156
|
+
*
|
|
157
|
+
* WHAT IS NOT HERE IS THE POINT. Console patching, the log session and its
|
|
158
|
+
* flush chain, the Escape listener, the enter queue, `playState` and the play
|
|
159
|
+
* epoch stay module-scope, because they belong to the play SESSION and not to
|
|
160
|
+
* an instance in it. Moving the log machinery in here would give N instances N
|
|
161
|
+
* interleaved log streams and read, later, as a game bug.
|
|
162
|
+
*/
|
|
163
|
+
interface PlayInstance {
|
|
164
|
+
/** The mount id — see above. What `?vgai-mount=` carries, what the realm and
|
|
165
|
+
* input gate are keyed by, what `setActiveSystems` registers under and what
|
|
166
|
+
* the session wire addresses. Empty until a composition has resolved. */
|
|
167
|
+
/** The element this instance mounted into (the primary's is the live
|
|
168
|
+
* document's — `live-document.ts` owns it; read it there). */
|
|
169
|
+
container: HTMLElement | null;
|
|
170
|
+
id: string;
|
|
171
|
+
/** A human-readable label for this instance — a HINT, never the mechanism.
|
|
172
|
+
* Defaults to "Instance 1"/"Instance 2"/… so a split view and its drivers read
|
|
173
|
+
* legibly; the ADDRESS is still the opaque mount id. A multiplayer game may
|
|
174
|
+
* choose to read it (e.g. as its own display name when it joins a room), but
|
|
175
|
+
* nothing here couples the instance to any player/network concept. */
|
|
176
|
+
name: string;
|
|
177
|
+
/** Container for this instance's root surfaces (the Game tab panel today). */
|
|
178
|
+
session: GameSession | null;
|
|
179
|
+
/** Native authored-subject presentation owned by the viewport host. */
|
|
180
|
+
presentation: { readonly worldId: string; dispose(): void } | null;
|
|
181
|
+
unregisterPerformanceSource: (() => void) | null;
|
|
182
|
+
unsubscribeSystemAdapters: (() => void) | null;
|
|
183
|
+
/** D15/T-D15.6 — whether this instance's manifest declares
|
|
184
|
+
* `determinism.seededRandom`. */
|
|
185
|
+
determinismDeclared: boolean;
|
|
186
|
+
resizeObserver: ResizeObserver | null;
|
|
187
|
+
/** Per-world adapters built for this instance, retained so teardown can
|
|
188
|
+
* detach their history resources. */
|
|
189
|
+
rootResources: AuthoringAdapter[];
|
|
190
|
+
/**
|
|
191
|
+
* THIS RUN's journal session — the id every adapter below journals into, and
|
|
192
|
+
* the one thing `exitPlayRootAuthoring` is allowed to expire.
|
|
193
|
+
*
|
|
194
|
+
* Play OWNS this session (`history/json-history-resource.ts`'s ownership
|
|
195
|
+
* block): a play-time edit is session-local by architecture, so pressing ■
|
|
196
|
+
* must leave nothing undoable in the edit-mode stack. Edit-mode's held
|
|
197
|
+
* surfaces own a DIFFERENT session (`AUTHORING_SESSION`) that no mount ends,
|
|
198
|
+
* which is what lets their undo survive a remount. Empty while not playing.
|
|
199
|
+
*/
|
|
200
|
+
journalSession: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createPlayInstance(): PlayInstance {
|
|
204
|
+
return {
|
|
205
|
+
id: '',
|
|
206
|
+
journalSession: '',
|
|
207
|
+
name: '',
|
|
208
|
+
container: null,
|
|
209
|
+
session: null,
|
|
210
|
+
presentation: null,
|
|
211
|
+
unregisterPerformanceSource: null,
|
|
212
|
+
unsubscribeSystemAdapters: null,
|
|
213
|
+
determinismDeclared: false,
|
|
214
|
+
resizeObserver: null,
|
|
215
|
+
rootResources: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Editor play mounts one instance. Everything this file exports means THIS
|
|
221
|
+
* one, which is why nothing above it has to know an instance has a name at
|
|
222
|
+
* all — editor focus and addressed-instance stay different questions
|
|
223
|
+
* (`active-systems.ts`).
|
|
224
|
+
*/
|
|
225
|
+
const _instance: PlayInstance = createPlayInstance();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* ADDITIONAL instances mounted beside the primary one.
|
|
229
|
+
*
|
|
230
|
+
* The primary (`_instance`) owns everything singular about a play session —
|
|
231
|
+
* the store scene it adopts, the authoring composite, the console patch, the
|
|
232
|
+
* camera transition, editor focus. An additional instance owns none of that:
|
|
233
|
+
* it is a second full mount of the SAME project (its own mount id, module
|
|
234
|
+
* graph, renderer and session) rendering into its own container, registered
|
|
235
|
+
* under its id so the session wire can address it (`systemsForInstance(id)`),
|
|
236
|
+
* and touching no singular focus state. That is what makes N instances a
|
|
237
|
+
* property of the MOUNT and not of the game — two seats of a multiplayer
|
|
238
|
+
* match, or one scene A/B'd under two seeds, are the same mechanism. Torn
|
|
239
|
+
* down with the session by `exitPlayMode`.
|
|
240
|
+
*/
|
|
241
|
+
const _additional: PlayInstance[] = [];
|
|
242
|
+
|
|
243
|
+
/** Root ids THIS play run recorded host-mount readiness for (`readiness.ts`),
|
|
244
|
+
* so its teardown drops exactly those and never a sibling's. Empty while
|
|
245
|
+
* stopped. */
|
|
246
|
+
let _hostMountedReadyRootIds: readonly string[] = [];
|
|
247
|
+
|
|
248
|
+
// KEYBOARD FOCUS across split-screen instances. With one editor keyboard only
|
|
249
|
+
// ONE instance can be driven at a time; this is which. `null` (and any stale
|
|
250
|
+
// id) resolves to the primary, so the default — and the single-instance case —
|
|
251
|
+
// is "the primary has the keyboard", exactly as before split screen existed.
|
|
252
|
+
// A click on an instance's viewport routes the keyboard to it
|
|
253
|
+
// (`setFocusedInstance`); the input gate + InputManager for every instance read
|
|
254
|
+
// this, so exactly the focused one is live and the rest are inert.
|
|
255
|
+
let _focusedInstanceId: string | null = null;
|
|
256
|
+
const focusListeners = new Set<() => void>();
|
|
257
|
+
|
|
258
|
+
/** The instance the shared keyboard currently drives — the focused id when it
|
|
259
|
+
* names a LIVE instance, else the primary (an unset focus, or one whose
|
|
260
|
+
* instance was torn down, falls back so the keyboard is never orphaned). */
|
|
261
|
+
export function focusedInstanceId(): string {
|
|
262
|
+
if (
|
|
263
|
+
_focusedInstanceId &&
|
|
264
|
+
(_focusedInstanceId === _instance.id ||
|
|
265
|
+
_additional.some((inst) => inst.id === _focusedInstanceId))
|
|
266
|
+
) {
|
|
267
|
+
return _focusedInstanceId;
|
|
268
|
+
}
|
|
269
|
+
return _instance.id;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The PRIMARY instance's mount id — the click target for focusing the primary
|
|
273
|
+
* viewport (`''` before a composition has resolved). */
|
|
274
|
+
export function primaryInstanceId(): string {
|
|
275
|
+
return _instance.id;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function subscribeFocusedInstance(listener: () => void): () => void {
|
|
279
|
+
focusListeners.add(listener);
|
|
280
|
+
return () => focusListeners.delete(listener);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function notifyFocusedInstance(): void {
|
|
284
|
+
for (const listener of focusListeners) listener();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Route the shared editor keyboard to instance `id` (the primary or any
|
|
288
|
+
* additional), and follow the ordinary engine convention that clicking a
|
|
289
|
+
* viewport also makes its runtime the one shown by diagnostic instruments.
|
|
290
|
+
* Re-gates every instance so only the focused one takes input. */
|
|
291
|
+
export function setFocusedInstance(id: string): void {
|
|
292
|
+
setInspectedInstance(id);
|
|
293
|
+
if (focusedInstanceId() === id) return;
|
|
294
|
+
_focusedInstanceId = id;
|
|
295
|
+
resyncInstanceInputs();
|
|
296
|
+
notifyFocusedInstance();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Whether instance `id` should receive input right now: play is running, the
|
|
300
|
+
* Game tab is active, this instance holds keyboard focus, AND our surface
|
|
301
|
+
* holds the keyboard.
|
|
302
|
+
*
|
|
303
|
+
* The fourth term is U2's, and it exists for the engine's `InputManager`
|
|
304
|
+
* specifically. `gated-globals.ts` already ANDs the same predicate into every
|
|
305
|
+
* raw `window`/`document` listener a PROJECT module registers, but
|
|
306
|
+
* `InputManager` is `@volter/game-runtime`'s — a dependency, not a project module, so
|
|
307
|
+
* the dev server's lexical shadow never covers it and it attaches to the real
|
|
308
|
+
* `window`. Under the Code-OSS frame that window also carries Monaco, so
|
|
309
|
+
* without this a keystroke meant for the source file beside the running game
|
|
310
|
+
* moves the game too. Standalone it is a constant true and nothing changes.
|
|
311
|
+
* See `@editor/surface-keyboard`. */
|
|
312
|
+
function instanceInputActive(id: string): boolean {
|
|
313
|
+
if (!_ctx) return false;
|
|
314
|
+
const { store } = _ctx;
|
|
315
|
+
return (
|
|
316
|
+
store.playState === 'playing' &&
|
|
317
|
+
store.activeViewportTab === 'play' &&
|
|
318
|
+
focusedInstanceId() === id &&
|
|
319
|
+
surfaceHoldsKeyboard()
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The first-party `InputManager` for an instance, or `undefined` — a session's
|
|
324
|
+
* game handle may lack one (an ingest mount, a partial double), so resolve it
|
|
325
|
+
* defensively. */
|
|
326
|
+
function instanceInput(inst: PlayInstance): { setEnabled(on: boolean): void } | undefined {
|
|
327
|
+
try {
|
|
328
|
+
return inst.session?.game?.input;
|
|
329
|
+
} catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Re-apply the enabled/disabled state of every live instance's InputManager
|
|
335
|
+
* from the current play/tab/focus predicate. Called whenever any of those
|
|
336
|
+
* change (store subscription, focus switch, an instance mounting). The raw
|
|
337
|
+
* window/document gates are closures over `instanceInputActive`, so they need
|
|
338
|
+
* no re-registration — they re-read focus on every event. */
|
|
339
|
+
function resyncInstanceInputs(): void {
|
|
340
|
+
if (_instance.id) instanceInput(_instance)?.setEnabled(instanceInputActive(_instance.id));
|
|
341
|
+
for (const inst of _additional) instanceInput(inst)?.setEnabled(instanceInputActive(inst.id));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* THE SURFACE TERM'S OWN EDGE. The store subscription re-gates on play/tab
|
|
346
|
+
* changes and `setFocusedInstance` on focus changes, but the fourth term above
|
|
347
|
+
* moves on neither: a person clicks into Monaco and nothing in the editor's own
|
|
348
|
+
* state has changed. `InputManager` is a LATCHED `setEnabled`, so unlike the
|
|
349
|
+
* raw gates (closures re-read per event) it has to be told. Module scope and
|
|
350
|
+
* never unsubscribed on purpose — the notification is a no-op with no instances
|
|
351
|
+
* mounted, and a lane-scoped subscription would have to be rebuilt on every
|
|
352
|
+
* mount for a predicate that is process-wide.
|
|
353
|
+
*/
|
|
354
|
+
subscribeSurfaceKeyboard(resyncInstanceInputs);
|
|
355
|
+
|
|
356
|
+
let _ctx: PlayModeContext | null = null;
|
|
357
|
+
const playModeBindingWaiters = new Set<() => void>();
|
|
358
|
+
|
|
359
|
+
/** The command listener can attach before the layout binds Play. */
|
|
360
|
+
function waitForPlayModeBinding(): Promise<void> {
|
|
361
|
+
if (_ctx) return Promise.resolve();
|
|
362
|
+
return new Promise<void>((resolve, reject) => {
|
|
363
|
+
const bound = () => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
playModeBindingWaiters.delete(bound);
|
|
366
|
+
resolve();
|
|
367
|
+
};
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
playModeBindingWaiters.delete(bound);
|
|
370
|
+
reject(
|
|
371
|
+
new Error('Play mode failed to start: the editor shell did not bind within 15 seconds.'),
|
|
372
|
+
);
|
|
373
|
+
}, 15_000);
|
|
374
|
+
playModeBindingWaiters.add(bound);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
const sessionListeners = new Set<() => void>();
|
|
378
|
+
const restartRequiredListeners = new Set<() => void>();
|
|
379
|
+
let restartRequiredReason: string | null = null;
|
|
380
|
+
|
|
381
|
+
export function getRestartRequiredReason(): string | null {
|
|
382
|
+
return restartRequiredReason;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function subscribeRestartRequired(listener: () => void): () => void {
|
|
386
|
+
restartRequiredListeners.add(listener);
|
|
387
|
+
return () => restartRequiredListeners.delete(listener);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function markRestartRequired(reason: string): void {
|
|
391
|
+
restartRequiredReason = reason;
|
|
392
|
+
for (const listener of restartRequiredListeners) listener();
|
|
393
|
+
editorHost().live.notifyChanged();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The exact `[play-mode] Restart required: …` warnings this session raised.
|
|
398
|
+
*
|
|
399
|
+
* They are kept because the unresolved-console ledger names conditions BY
|
|
400
|
+
* THEIR TEXT, and a remount is the event that resolves them: measured
|
|
401
|
+
* 2026-08-29, `vgai restart` reported "↻ Restarted — session ready" while its
|
|
402
|
+
* own named warning stayed in `vgai console` forever, because the ledger's
|
|
403
|
+
* automatic clearing rule is a PAGE LOAD and a remount is not one — only
|
|
404
|
+
* `game.reloadPage()` could silence a warning the named verb had already
|
|
405
|
+
* fixed. `clearRestartRequired` now reports them resolved (ledger clearing
|
|
406
|
+
* rule (c), `server/console-ledger.ts`), so the verb clears its own condition.
|
|
407
|
+
*/
|
|
408
|
+
const restartRequiredWarnings = new Set<string>();
|
|
409
|
+
|
|
410
|
+
/** Warn that a restart is required AND remember the sentence, so the restart
|
|
411
|
+
* that resolves it can retire exactly this condition. */
|
|
412
|
+
function warnRestartRequired(message: string): void {
|
|
413
|
+
restartRequiredWarnings.add(message);
|
|
414
|
+
editorConsole.warn(message, 'play-mode');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearRestartRequired(): void {
|
|
418
|
+
if (restartRequiredWarnings.size > 0) {
|
|
419
|
+
// The bare sentence, exactly as `editorConsole.warn` reported it — the
|
|
420
|
+
// `[play-mode]` the CLI prints is rendered from `source`, not stored text.
|
|
421
|
+
const conditions = [...restartRequiredWarnings].map((message) => ({
|
|
422
|
+
severity: 'warn' as const,
|
|
423
|
+
message,
|
|
424
|
+
}));
|
|
425
|
+
restartRequiredWarnings.clear();
|
|
426
|
+
void sendControl('console-resolved', { conditions, by: 'play-mode' });
|
|
427
|
+
}
|
|
428
|
+
if (restartRequiredReason === null) return;
|
|
429
|
+
restartRequiredReason = null;
|
|
430
|
+
for (const listener of restartRequiredListeners) listener();
|
|
431
|
+
editorHost().live.notifyChanged();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function notifySessionListeners(): void {
|
|
435
|
+
syncPlayPresentationActivity();
|
|
436
|
+
for (const listener of sessionListeners) listener();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function subscribeGameSession(listener: () => void): () => void {
|
|
440
|
+
sessionListeners.add(listener);
|
|
441
|
+
return () => sessionListeners.delete(listener);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function getGameProfiler(): PerformanceProfiler | null {
|
|
445
|
+
return _instance.session?.game.profiler ?? null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Play-mode generation counter. Bumped on every enterPlayMode and every
|
|
450
|
+
* exitPlayMode. enterPlayMode captures the value before its async boot and
|
|
451
|
+
* re-checks it around `createGameRuntime` — if exitPlayMode (Stop/Escape) ran
|
|
452
|
+
* while the runtime was still booting, the freshly-created session belongs to
|
|
453
|
+
* an already-exited play and must be stopped and abandoned, NOT adopted.
|
|
454
|
+
* Without this, a stop-during-boot left the store swapped onto the orphaned
|
|
455
|
+
* live game scene (empty hierarchy in edit mode) with the session leaked
|
|
456
|
+
* (RAF loop, Rapier world, WebGL context never released).
|
|
457
|
+
*/
|
|
458
|
+
let _playEpoch = 0;
|
|
459
|
+
// #146 — when the MOST RECENT play run began (ms epoch), or null before any
|
|
460
|
+
// run. The relay snapshot's `pageErrors` uses this as its freshness fence.
|
|
461
|
+
//
|
|
462
|
+
// PD-1: this used to be nulled by `exitPlayMode()`, which meant the errors of
|
|
463
|
+
// a run that FAILED became invisible the instant the failed run rolled back —
|
|
464
|
+
// `vgai status` reported `pageErrors: []` for a play that had just thrown, the
|
|
465
|
+
// exact "every diagnostic says healthy" symptom. The fence's job is to exclude
|
|
466
|
+
// a PREVIOUS run's noise, and the next `enterPlayMode` re-stamping it does
|
|
467
|
+
// that; dropping it on exit only ever hid the evidence of the last run.
|
|
468
|
+
let _playStartedAtMs: number | null = null;
|
|
469
|
+
// The CLOSING half of the same fence: when the most recent run's teardown
|
|
470
|
+
// finished, or null while a run is live (and before the first run).
|
|
471
|
+
//
|
|
472
|
+
// Why an end and not just a start: with an open-ended window every editor error
|
|
473
|
+
// logged AFTER a run stopped still counted as "during the play run", so it fell
|
|
474
|
+
// into the play-fenced `consoleErrors` facet — which `vgai status` renders only
|
|
475
|
+
// while play is live. One play run, and every later editor-frame error went
|
|
476
|
+
// invisible again, which is the exact defect the session-lifetime facets exist
|
|
477
|
+
// to close. Stamped at the END of `exitPlayMode`, so a FAILED run's errors (all
|
|
478
|
+
// logged before its rollback completes) stay inside the window and PD-1 above
|
|
479
|
+
// still holds.
|
|
480
|
+
let _playEndedAtMs: number | null = null;
|
|
481
|
+
|
|
482
|
+
// T6.3: install the gated window/document proxies once so the dev server's/
|
|
483
|
+
// browser-transpile's GAME_GLOBALS_PRELUDE (prepended to project modules) has
|
|
484
|
+
// something to resolve to. No-op outside a browser (headless unit tests).
|
|
485
|
+
installGatedGameGlobals();
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The warm-restart HMR bracket's pause/resume wrapper (§7.1 item 1 /
|
|
489
|
+
* probe3-warm-restart-unpauses: the bracket used to call
|
|
490
|
+
* `_instance.session.pause(); await hotReload(...); _instance.session.resume();`
|
|
491
|
+
* unconditionally, so it silently un-paused a user-paused game — the UI kept
|
|
492
|
+
* saying "paused" while the simulation resumed running). Fix: capture
|
|
493
|
+
* whether the game was ALREADY paused from the SAME source of truth
|
|
494
|
+
* `pause()`/`resume()` drive (`session.game.play.paused`, `game.ts:713`)
|
|
495
|
+
* BEFORE unconditionally pausing for the reload, and only resume afterward
|
|
496
|
+
* if it was not already paused — `pause()`/`resume()` are idempotent, so an
|
|
497
|
+
* unconditional `pause()` up front is always safe. try/finally so a throw
|
|
498
|
+
* mid-`reload()` still restores the correct pre-bracket state (a throw must
|
|
499
|
+
* never leave a previously-RUNNING game stuck paused). Exported for direct
|
|
500
|
+
* unit testing without any `import.meta.hot`/Vite HMR event machinery — see
|
|
501
|
+
* `packages/editor/test/play-mode-warm-restart-pause.test.ts`.
|
|
502
|
+
*/
|
|
503
|
+
export async function runWarmRestartPauseBracket(
|
|
504
|
+
session: GameSession,
|
|
505
|
+
reload: () => Promise<void>,
|
|
506
|
+
): Promise<void> {
|
|
507
|
+
const wasPaused = session.game.play.paused;
|
|
508
|
+
session.pause();
|
|
509
|
+
try {
|
|
510
|
+
await reload();
|
|
511
|
+
} finally {
|
|
512
|
+
if (!wasPaused) session.resume();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
let _unsubStore: (() => void) | null = null;
|
|
516
|
+
/** Browser-mode component-source watch (Phase A2); null in server mode / stopped. */
|
|
517
|
+
/**
|
|
518
|
+
* The authoring override that was active before THIS play session installed its
|
|
519
|
+
* own (normally `null` — nothing else authors while playing today, but this
|
|
520
|
+
* restores whatever was there rather than assuming null. `undefined` ⇒ this play
|
|
521
|
+
* session never installed one (e.g. it bailed before adopting the scene) — exit
|
|
522
|
+
* must then leave the authoring override untouched.
|
|
523
|
+
*/
|
|
524
|
+
let _priorAuthoringOverride: AuthoringAdapter | null | undefined;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Replace an adapter's persistence surface without changing its live authoring
|
|
528
|
+
* providers. Play changes are session-local for every world count (D19).
|
|
529
|
+
*
|
|
530
|
+
* EPHEMERAL IS A PIPE DESTINATION, not a parallel stack: the wrapped providers
|
|
531
|
+
* still perform their live writes, and each one then runs the SAME
|
|
532
|
+
* `resolve → write → record` pipe every other lane runs — resolving to the
|
|
533
|
+
* ephemeral destination, which has no writer arm. So "discarded on stop" is an
|
|
534
|
+
* ack of exactly the shape "written to src/world.tsx" is, produced the same
|
|
535
|
+
* way. Letting the underlying adapter's own ack through would name a file this
|
|
536
|
+
* session's edits are structurally barred from reaching.
|
|
537
|
+
*/
|
|
538
|
+
function withEphemeralPersistence(base: AuthoringAdapter): AuthoringAdapter {
|
|
539
|
+
const capabilities = { ...base.capabilities, persist: false };
|
|
540
|
+
const persistence = createEphemeralPersistence();
|
|
541
|
+
// No `report`: a play session refusing to persist is the architecture, not a
|
|
542
|
+
// surprise, and saying so on every edit would be noise. No `record` either —
|
|
543
|
+
// the base adapter already closed the gesture, and a play session's history
|
|
544
|
+
// is its own.
|
|
545
|
+
const ephemeralAck = (): Promise<WriteAck> =>
|
|
546
|
+
runWritePipe({
|
|
547
|
+
resolve: () =>
|
|
548
|
+
resolvesLiveOnly('play edits are session-local by architecture', EPHEMERAL_DESTINATION),
|
|
549
|
+
record: () => undefined,
|
|
550
|
+
});
|
|
551
|
+
const baseTransforms = base.transforms;
|
|
552
|
+
const transforms: TransformProvider | undefined = baseTransforms && {
|
|
553
|
+
...baseTransforms,
|
|
554
|
+
get: (id) => baseTransforms.get(id),
|
|
555
|
+
beginEdit: (id) => baseTransforms.beginEdit(id),
|
|
556
|
+
apply: (id, t) => baseTransforms.apply(id, t),
|
|
557
|
+
endEdit: (id) => {
|
|
558
|
+
baseTransforms.endEdit(id);
|
|
559
|
+
return ephemeralAck();
|
|
560
|
+
},
|
|
561
|
+
// A REMOVAL IS A WRITE, and a play session's writes are session-local by
|
|
562
|
+
// architecture — so the base's door is shadowed rather than spread through.
|
|
563
|
+
// Inherited unchanged, `{...baseTransforms}` would hand the running game's
|
|
564
|
+
// revert straight to the source lane's attribute deleter, and a play-mode
|
|
565
|
+
// gesture would delete a line of the game's own TSX. There is nothing to
|
|
566
|
+
// apply live either: the value in force once a channel is absent is the
|
|
567
|
+
// one the component declares, which only a remount can report.
|
|
568
|
+
...(baseTransforms.remove ? { remove: () => ephemeralAck() } : {}),
|
|
569
|
+
};
|
|
570
|
+
const baseInspector = base.inspector;
|
|
571
|
+
const inspector: InspectorProvider | undefined = baseInspector && {
|
|
572
|
+
...baseInspector,
|
|
573
|
+
properties: (id) => baseInspector.properties(id),
|
|
574
|
+
get: (id, path) => baseInspector.get(id, path),
|
|
575
|
+
set: (id, path, value) => {
|
|
576
|
+
baseInspector.set(id, path, value);
|
|
577
|
+
return ephemeralAck();
|
|
578
|
+
},
|
|
579
|
+
// Same reason as `transforms.remove` above: spread unchanged, the base's
|
|
580
|
+
// revert arrow would delete a JSX attribute from the game's source while
|
|
581
|
+
// the game is PLAYING.
|
|
582
|
+
...(baseInspector.remove ? { remove: () => ephemeralAck() } : {}),
|
|
583
|
+
};
|
|
584
|
+
return new Proxy(base, {
|
|
585
|
+
get(target, property) {
|
|
586
|
+
if (property === 'capabilities') return capabilities;
|
|
587
|
+
if (property === 'persistence') return persistence;
|
|
588
|
+
if (property === 'transforms' && transforms) return transforms;
|
|
589
|
+
if (property === 'inspector' && inspector) return inspector;
|
|
590
|
+
const value = Reflect.get(target, property, target) as unknown;
|
|
591
|
+
// Preserve the original class receiver for prototype methods while the
|
|
592
|
+
// Proxy itself preserves `instanceof` identity for inspector routing.
|
|
593
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Restore whatever authoring override (if any) was active before play started. */
|
|
599
|
+
function restorePriorAuthoring(): void {
|
|
600
|
+
if (_priorAuthoringOverride === undefined) return;
|
|
601
|
+
setActiveAuthoring(_priorAuthoringOverride);
|
|
602
|
+
_priorAuthoringOverride = undefined;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* D19's one play authoring path for N >= 1. Every mounted root is dispatched
|
|
607
|
+
* by its native surface tag to its direct live adapter. The viewport host may
|
|
608
|
+
* separately present one of those native subjects; presentation never decides
|
|
609
|
+
* whether the other roots exist or remain authorable. Ordinary child edits
|
|
610
|
+
* stay in the LIVE world — a dom root included (`react-play-live-authoring.ts`).
|
|
611
|
+
* A presented OID Three child may additionally expose the explicit
|
|
612
|
+
* `transforms.sourceCommit` verb; that one user gesture is the only route from
|
|
613
|
+
* this Play adapter back to source.
|
|
614
|
+
*
|
|
615
|
+
* THIS FUNCTION AWAITS (the canvas branch dynamic-imports five modules), and
|
|
616
|
+
* `exitPlayMode` is synchronous — so a Stop landing mid-await runs the whole
|
|
617
|
+
* play teardown, INCLUDING `restorePriorAuthoring` (which consumes
|
|
618
|
+
* `_priorAuthoringOverride`), and then this function resumes and installs play
|
|
619
|
+
* authoring over the just-restored edit authoring, with nothing left able to
|
|
620
|
+
* undo it. `isCurrentGeneration` is the guard: it is re-read after the awaits
|
|
621
|
+
* and before anything is committed, and the adapters built so far are disposed
|
|
622
|
+
* on the abort path rather than stranded.
|
|
623
|
+
*/
|
|
624
|
+
async function installPlayRootAuthoring(
|
|
625
|
+
store: EditorShellStore,
|
|
626
|
+
roots: readonly RootInstance[],
|
|
627
|
+
manifest: Awaited<ReturnType<typeof fetchGameManifest>>,
|
|
628
|
+
presentedWorldId: string | null,
|
|
629
|
+
isCurrentGeneration: () => boolean,
|
|
630
|
+
): Promise<void> {
|
|
631
|
+
_priorAuthoringOverride = getAuthoringOverride();
|
|
632
|
+
const children: CompositeChild[] = [];
|
|
633
|
+
const resources: AuthoringAdapter[] = [];
|
|
634
|
+
/** Drop everything built so far — this play is over, so these adapters have
|
|
635
|
+
* no session to belong to and no teardown path that would ever reach them
|
|
636
|
+
* (`exitPlayRootAuthoring` walks `_instance.rootResources`, which this run
|
|
637
|
+
* never gets to assign). */
|
|
638
|
+
const abandon = (): void => {
|
|
639
|
+
for (const adapter of resources) {
|
|
640
|
+
if ('dispose' in adapter && typeof adapter.dispose === 'function') adapter.dispose();
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
for (const world of roots) {
|
|
644
|
+
const declaration = manifest ? rootById(manifest, world.id) : undefined;
|
|
645
|
+
const composition = declaration
|
|
646
|
+
? {
|
|
647
|
+
zOrder: declaration.zOrder,
|
|
648
|
+
pausable: declaration.pausable,
|
|
649
|
+
...(declaration.entry ? { content: `Entry · ${declaration.entry}` } : {}),
|
|
650
|
+
}
|
|
651
|
+
: {};
|
|
652
|
+
// D-N4/D-N8: an ingested React game's DOM is disclosed as a read-only
|
|
653
|
+
// boundary. Normalizing composition must never silently grant JSX/DOM
|
|
654
|
+
// authoring to vendored source; the universal tree and write authority
|
|
655
|
+
// are independent concerns.
|
|
656
|
+
if (declaration?.adapter.identity === 'ingest-react') {
|
|
657
|
+
children.push({
|
|
658
|
+
worldId: world.id,
|
|
659
|
+
kind: world.kind,
|
|
660
|
+
adapter: new BoundaryAuthoringAdapter(store, {
|
|
661
|
+
id: world.id,
|
|
662
|
+
kind: world.kind,
|
|
663
|
+
adapter: declaration.adapter.identity,
|
|
664
|
+
entryOrScenePath: declaration.entry,
|
|
665
|
+
zOrder: declaration.zOrder,
|
|
666
|
+
pausable: declaration.pausable,
|
|
667
|
+
}),
|
|
668
|
+
...composition,
|
|
669
|
+
});
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const mounted = world.mounted;
|
|
673
|
+
if (mounted.kind === 'three') {
|
|
674
|
+
// The presented subject uses OID identity so Edit→Play selection stays
|
|
675
|
+
// continuous. A headless or non-Three host can omit presentation; its
|
|
676
|
+
// mounted tree still gets an honest structural live projection.
|
|
677
|
+
// `frameControl: 'host'` on the structural branch follows from the same
|
|
678
|
+
// tick-ownership fact: play's own loop drives the root and this adapter
|
|
679
|
+
// holds no handle that can stop it for a gesture.
|
|
680
|
+
const isPresentedSubject = world.id === presentedWorldId && mounted.scene === store.scene;
|
|
681
|
+
const sourceAnchor = isPresentedSubject ? await readOidSourceAnchors() : undefined;
|
|
682
|
+
if (!isCurrentGeneration()) {
|
|
683
|
+
abandon();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const adapter = isPresentedSubject
|
|
687
|
+
? oidThree(store, mounted.scene, liveWorldId(world.id), playRunJournal(world.id), {
|
|
688
|
+
explicitSourceCommit: true,
|
|
689
|
+
sourceAnchor,
|
|
690
|
+
})
|
|
691
|
+
: structuralThree(store, mounted.scene, {
|
|
692
|
+
frameControl: 'host',
|
|
693
|
+
journal: playRunJournal(world.id),
|
|
694
|
+
});
|
|
695
|
+
resources.push(adapter);
|
|
696
|
+
children.push({
|
|
697
|
+
worldId: world.id,
|
|
698
|
+
kind: world.kind,
|
|
699
|
+
adapter: withEphemeralPersistence(adapter),
|
|
700
|
+
...composition,
|
|
701
|
+
});
|
|
702
|
+
} else if (mounted.kind === 'canvas') {
|
|
703
|
+
if (mounted.substrate.name === 'babylon') {
|
|
704
|
+
const { BabylonAuthoringAdapter } = await import(
|
|
705
|
+
'../host/authoring/babylon-authoring-adapter'
|
|
706
|
+
);
|
|
707
|
+
if (!isCurrentGeneration()) {
|
|
708
|
+
abandon();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const adapter = new BabylonAuthoringAdapter(
|
|
712
|
+
mounted.substrate
|
|
713
|
+
.root as import('../host/authoring/babylon-authoring-adapter').BabylonEngineLike,
|
|
714
|
+
store,
|
|
715
|
+
{
|
|
716
|
+
canvas: mounted.canvas,
|
|
717
|
+
api: mounted.substrate.api as never,
|
|
718
|
+
provenance: {
|
|
719
|
+
source: 'live',
|
|
720
|
+
label: 'live',
|
|
721
|
+
detail: 'Native Babylon.js play scene; ordinary Play edits are ephemeral.',
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
);
|
|
725
|
+
resources.push(adapter);
|
|
726
|
+
children.push({
|
|
727
|
+
worldId: world.id,
|
|
728
|
+
kind: world.kind,
|
|
729
|
+
adapter: withEphemeralPersistence(adapter),
|
|
730
|
+
...composition,
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (mounted.substrate.name !== 'pixi') {
|
|
735
|
+
const adapter = new BoundaryAuthoringAdapter(
|
|
736
|
+
store,
|
|
737
|
+
{
|
|
738
|
+
id: world.id,
|
|
739
|
+
kind: world.kind,
|
|
740
|
+
adapter: mounted.substrate.name,
|
|
741
|
+
entryOrScenePath: declaration?.entry,
|
|
742
|
+
zOrder: declaration?.zOrder ?? 0,
|
|
743
|
+
pausable: declaration?.pausable ?? true,
|
|
744
|
+
},
|
|
745
|
+
`No authoring adapter is registered for canvas substrate "${mounted.substrate.name}".`,
|
|
746
|
+
);
|
|
747
|
+
resources.push(adapter);
|
|
748
|
+
children.push({ worldId: world.id, kind: world.kind, adapter, ...composition });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const stage = mounted.substrate.root as import('pixi.js').Container;
|
|
752
|
+
const [physicsRegistry, physicsAdapters, pixiAuthoring, pixiWriteTarget, canvasRuntime] =
|
|
753
|
+
await Promise.all([
|
|
754
|
+
import('@volter/game-runtime/pixi/physics-registry'),
|
|
755
|
+
import('@volter/game-runtime/pixi/system-adapters'),
|
|
756
|
+
import('../host/authoring/pixi-authoring-adapter'),
|
|
757
|
+
import('../host/authoring/pixi-live-write-target'),
|
|
758
|
+
import('../host/canvas-entry-runtime'),
|
|
759
|
+
]);
|
|
760
|
+
// Stop landed while those imports were in flight — see this function's
|
|
761
|
+
// doc comment. Bail before building (and before the `await` below).
|
|
762
|
+
if (!isCurrentGeneration()) {
|
|
763
|
+
abandon();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const { createPhysics2DRegistry } = physicsRegistry;
|
|
767
|
+
const { createPhysicsAdapter2D } = physicsAdapters;
|
|
768
|
+
const { PixiAuthoringAdapter } = pixiAuthoring;
|
|
769
|
+
const { createLiveCanvasWriteTarget } = pixiWriteTarget;
|
|
770
|
+
const physics = createPhysicsAdapter2D(world.physics2d ?? createPhysics2DRegistry());
|
|
771
|
+
const adapter = new PixiAuthoringAdapter(stage, store, {
|
|
772
|
+
// A played canvas world mounts through the SAME adjudicator Edit uses
|
|
773
|
+
// (`resolveCanvasEntryAdapterForEditor`), so its stage is the project
|
|
774
|
+
// graph's under the packaged runtime and this adapter's namespace has
|
|
775
|
+
// to be too — see `../vite-plugin-module-doorways.ts`.
|
|
776
|
+
pixi: await canvasRuntime.resolveCanvasPixiForEditor(),
|
|
777
|
+
target: createLiveCanvasWriteTarget({ physics }),
|
|
778
|
+
journal: playRunJournal(world.id),
|
|
779
|
+
// Every root surface fills the game container exactly, so the
|
|
780
|
+
// container's own rect IS this world's canvas rect.
|
|
781
|
+
surface: getGameContainer,
|
|
782
|
+
});
|
|
783
|
+
resources.push(adapter);
|
|
784
|
+
children.push({
|
|
785
|
+
worldId: world.id,
|
|
786
|
+
kind: world.kind,
|
|
787
|
+
adapter: withEphemeralPersistence(adapter),
|
|
788
|
+
...composition,
|
|
789
|
+
});
|
|
790
|
+
} else if (mounted.kind === 'dom') {
|
|
791
|
+
// Same regime as the three/pixi children above: edits apply to the LIVE
|
|
792
|
+
// world and die with the session. The adapter is Edit mode's (so OID node
|
|
793
|
+
// identity — and Edit→Play selection continuity — is unchanged); only its
|
|
794
|
+
// write DESTINATION is swapped to the running DOM. See
|
|
795
|
+
// `authoring/react-play-live-authoring.ts`.
|
|
796
|
+
const adapter = createReactPlayAuthoringAdapter(mounted.container, store);
|
|
797
|
+
resources.push(adapter);
|
|
798
|
+
children.push({
|
|
799
|
+
worldId: world.id,
|
|
800
|
+
kind: world.kind,
|
|
801
|
+
adapter: withEphemeralPersistence(adapter),
|
|
802
|
+
...composition,
|
|
803
|
+
});
|
|
804
|
+
} else {
|
|
805
|
+
// Exhaustiveness guard (§7.4-2): the pre-existing if/else-if chain over
|
|
806
|
+
// `RootInstance.kind` had no trailing else — a hypothetical 4th kind
|
|
807
|
+
// would silently get NO authoring child (no error, just missing
|
|
808
|
+
// authoring for that world) rather than failing loudly/at compile time.
|
|
809
|
+
assertNever(mounted, 'installPlayRootAuthoring');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// THE COMMIT GATE. Everything below writes editor-wide state that only a live
|
|
813
|
+
// play run may own — the instance's resource list, the active authoring
|
|
814
|
+
// override, the dev handle. Re-read the generation here, after every await
|
|
815
|
+
// above, so a Stop that landed mid-install cannot have its restored edit
|
|
816
|
+
// authoring overwritten by this resumed one.
|
|
817
|
+
if (!isCurrentGeneration()) {
|
|
818
|
+
abandon();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
_instance.rootResources = resources;
|
|
822
|
+
const projection = hierarchyProjectionFromProjectConfig(getCurrentProject()?.config);
|
|
823
|
+
const composite = new CompositeAuthoringAdapter(children, undefined, projection);
|
|
824
|
+
setActiveAuthoring(composite);
|
|
825
|
+
// `setActiveAuthoring` is a plain module-level variable, not React state —
|
|
826
|
+
// the left hierarchy panel only re-checks `hasAuthoringOverride()` when the
|
|
827
|
+
// store notifies (same reason `ingest/mount-ingest-root.ts` calls this right after
|
|
828
|
+
// installing its own override).
|
|
829
|
+
store.notifyIngestEdit();
|
|
830
|
+
|
|
831
|
+
// Dev/e2e diagnostic handle — the SAME pattern `ingest/mount-ingest-root.ts`'s
|
|
832
|
+
// `window.__vgaiIngest`/`window.__vgaiIngest2D` use:
|
|
833
|
+
// `store.saveNow()` (Ctrl/Cmd+S) and `_autoSave()` are both structurally
|
|
834
|
+
// guarded OFF while ANY authoring override is active (`hasAuthoringOverride()`
|
|
835
|
+
// / the play-state check) — the adapter's OWN `persistence.save()` is the
|
|
836
|
+
// only way an override session's edits reach disk, so tests/tooling need a
|
|
837
|
+
// handle to call it directly, exactly as the ingest sessions expose.
|
|
838
|
+
if (import.meta.env.DEV) {
|
|
839
|
+
(window as unknown as Record<string, unknown>)['__vgaiMultiRoot'] = {
|
|
840
|
+
adapter: composite,
|
|
841
|
+
worldIds: children.map((c) => c.worldId),
|
|
842
|
+
store,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** This run's journal for one world — see {@link PlayInstance.journalSession}. */
|
|
848
|
+
function playRunJournal(worldId: string): JournalSubject {
|
|
849
|
+
return playJournal(_instance.id, worldId);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Teardown every per-world authoring resource owned by the play session, and
|
|
854
|
+
* END THE RUN'S JOURNAL.
|
|
855
|
+
*
|
|
856
|
+
* This is the ONE teardown path allowed to expire a journal session
|
|
857
|
+
* (`history/json-history-resource.ts`'s ownership block), and it may expire
|
|
858
|
+
* only the session play itself minted. An adapter's own `dispose()` merely
|
|
859
|
+
* detaches: it is a SHARER of whatever session it was handed, and a sharer that
|
|
860
|
+
* could end a session is how a held surface's undo stack came to be emptied by
|
|
861
|
+
* every remount.
|
|
862
|
+
*/
|
|
863
|
+
function exitPlayRootAuthoring(store: EditorShellStore): void {
|
|
864
|
+
for (const adapter of _instance.rootResources) {
|
|
865
|
+
if ('dispose' in adapter && typeof adapter.dispose === 'function') adapter.dispose();
|
|
866
|
+
}
|
|
867
|
+
_instance.rootResources = [];
|
|
868
|
+
// Play edits are session-local by architecture: nothing this run journaled
|
|
869
|
+
// may still be undoable once the run is over.
|
|
870
|
+
if (_instance.journalSession) {
|
|
871
|
+
store.projectHistory?.expireSession(_instance.journalSession);
|
|
872
|
+
_instance.journalSession = '';
|
|
873
|
+
}
|
|
874
|
+
if (import.meta.env.DEV) {
|
|
875
|
+
delete (window as unknown as Record<string, unknown>)['__vgaiMultiRoot'];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* W5: decides whether a keydown Escape should stop play mode. Escape-to-stop
|
|
880
|
+
* is an EDITOR command (unlike other play input, it must fire regardless of
|
|
881
|
+
* `activeViewportTab` — Escape from the Scene tab is expected to stop play
|
|
882
|
+
* too), so it is gated separately from the game-input predicate. Escape
|
|
883
|
+
* consumed by an overlay (menu/dialog/popover called preventDefault) or
|
|
884
|
+
* aimed at a text edit (input/textarea/select/contenteditable — the user
|
|
885
|
+
* means "cancel this edit", not "stop play") must not stop play.
|
|
886
|
+
* Exported for unit testing with synthetic KeyboardEvents.
|
|
887
|
+
*/
|
|
888
|
+
export function shouldEscapeStopPlay(e: KeyboardEvent): boolean {
|
|
889
|
+
if (e.defaultPrevented) return false;
|
|
890
|
+
if (isEditableTarget(e.target)) return false;
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let _escapeListener: ((e: KeyboardEvent) => void) | null = null;
|
|
895
|
+
let _originalConsoleLog: typeof console.log | null = null;
|
|
896
|
+
let _originalConsoleInfo: typeof console.info | null = null;
|
|
897
|
+
let _originalConsoleWarn: typeof console.warn | null = null;
|
|
898
|
+
let _originalConsoleError: typeof console.error | null = null;
|
|
899
|
+
let _logFlushChain: Promise<unknown> = Promise.resolve();
|
|
900
|
+
let _flushInterval: ReturnType<typeof setInterval> | null = null;
|
|
901
|
+
let _pendingEntries: LogEntry[] = [];
|
|
902
|
+
let _lastPersistedDebugEventSeq = 0;
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The per-entry half of a play log's identity (`@volter/editor-sdk`'s
|
|
906
|
+
* `play/log-format.ts` owns the format; the run's session/project/name/start
|
|
907
|
+
* are a header line the server writes once, and are deliberately NOT repeated
|
|
908
|
+
* here).
|
|
909
|
+
*
|
|
910
|
+
* Both fields are stamped ONLY when this writer genuinely knows them, and
|
|
911
|
+
* absence is a real answer:
|
|
912
|
+
* - `simSpeed` is the live loop's `timeScale` — an instrument can change it
|
|
913
|
+
* mid-run, so it is a genuine per-entry fact. Absent before the game exists.
|
|
914
|
+
* - `world` is the world the run PRESENTS (the adopted three root), or the
|
|
915
|
+
* game's single root when it has exactly one and attribution is therefore
|
|
916
|
+
* unambiguous. A multi-root run with no presented world gets nothing rather
|
|
917
|
+
* than a guess — the anti-shim rule applies to evidence too.
|
|
918
|
+
*/
|
|
919
|
+
function playLogRunStamp(): { world?: string; simSpeed?: number } {
|
|
920
|
+
const game = _instance.session?.game;
|
|
921
|
+
if (!game) return {};
|
|
922
|
+
const roots = game.roots;
|
|
923
|
+
const world = _instance.presentation?.worldId ?? (roots.length === 1 ? roots[0]?.id : undefined);
|
|
924
|
+
const timeScale = game.loop.timeScale;
|
|
925
|
+
return {
|
|
926
|
+
...(world !== undefined ? { world } : {}),
|
|
927
|
+
...(typeof timeScale === 'number' ? { simSpeed: timeScale } : {}),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function drainDebugEventsToPlayLog(): void {
|
|
932
|
+
const debug = _instance.session?.game?.systemAdapters?.debug;
|
|
933
|
+
if (!debug) return;
|
|
934
|
+
try {
|
|
935
|
+
const converted = debugEventsToLogEntries(
|
|
936
|
+
debug.events(_lastPersistedDebugEventSeq),
|
|
937
|
+
_lastPersistedDebugEventSeq,
|
|
938
|
+
);
|
|
939
|
+
_lastPersistedDebugEventSeq = converted.lastSeq;
|
|
940
|
+
const stamp = playLogRunStamp();
|
|
941
|
+
for (const entry of converted.entries) _pendingEntries.push({ ...entry, ...stamp });
|
|
942
|
+
} catch {
|
|
943
|
+
// Logging is evidence, never a reason to break the game loop.
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Asset bytes have changed, but a running game retains its own instances.
|
|
948
|
+
const stopAssetReload = onAssetReload(() => {
|
|
949
|
+
if (!_instance.session) return;
|
|
950
|
+
markRestartRequired(
|
|
951
|
+
'Project assets changed — restart play to load the revised models or textures.',
|
|
952
|
+
);
|
|
953
|
+
});
|
|
954
|
+
if (import.meta.hot) import.meta.hot.dispose(stopAssetReload);
|
|
955
|
+
|
|
956
|
+
// --- Script HMR ---
|
|
957
|
+
if (import.meta.hot) {
|
|
958
|
+
import.meta.hot.on('vgai:restart-required', (data: { file: string }) => {
|
|
959
|
+
if (!_instance.session) return;
|
|
960
|
+
const file = data.file.split('/').pop() ?? data.file;
|
|
961
|
+
markRestartRequired(`${file} changed and cannot be applied safely while the game is running.`);
|
|
962
|
+
warnRestartRequired(`Restart required: ${file} changed.`);
|
|
963
|
+
});
|
|
964
|
+
// An R3F-dialect entry file changed while the game is RUNNING. Play mode
|
|
965
|
+
// deliberately does NOT full-reload for `r3f-entry` files (the
|
|
966
|
+
// dev server swallows them from stock HMR and fires this custom event; the
|
|
967
|
+
// design session that normally absorbs it is suspended during play) — but
|
|
968
|
+
// stale must never be SILENT. Mark the session restart-required: the
|
|
969
|
+
// PlayBar's Restart button lights up (variant 'primary') carrying this
|
|
970
|
+
// reason, and ONE click remounts every root from fresh source and
|
|
971
|
+
// re-enters play (`enterPlayMode` re-imports the entry with a
|
|
972
|
+
// cache-busting query — see `loadProjectScripts`). `vgai status` reports
|
|
973
|
+
// the same pending-restart state via `collectState().restartRequired`, so
|
|
974
|
+
// agents get the signal humans get. EDIT-mode behavior is unchanged
|
|
975
|
+
// (`_instance.session` is null there; absorb-by-remount stays as landed — see
|
|
976
|
+
// r3f-design-session.ts).
|
|
977
|
+
import.meta.hot.on('vgai:r3f-entry-update', (data: { file: string }) => {
|
|
978
|
+
if (!_instance.session) return;
|
|
979
|
+
const file = data.file.split('/').pop() ?? data.file;
|
|
980
|
+
markRestartRequired(
|
|
981
|
+
`${file} changed while playing — the running R3F world is stale until play restarts.`,
|
|
982
|
+
);
|
|
983
|
+
warnRestartRequired(`Restart required: ${file} changed while playing.`);
|
|
984
|
+
});
|
|
985
|
+
import.meta.hot.on('vgai:script-update', async (data: { file: string }) => {
|
|
986
|
+
// Project-tool source belongs to editor chrome. The tool contribution
|
|
987
|
+
// store re-imports and remounts it; no game root can become stale from an
|
|
988
|
+
// editor-only document changing.
|
|
989
|
+
if (isEditorLanePath(data.file)) return;
|
|
990
|
+
const project = getCurrentProject();
|
|
991
|
+
if (!project) return;
|
|
992
|
+
if (!_instance.session || !_ctx) return;
|
|
993
|
+
markRestartRequired(
|
|
994
|
+
`${data.file.split('/').pop() ?? data.file} changed and requires remounting all roots.`,
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** The live document's container element (`live-document.ts` owns it). */
|
|
1000
|
+
export function getGameContainer(): HTMLElement | null {
|
|
1001
|
+
return editorHost().workspace.liveDocument.container();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Move keyboard focus and the hotkey scope onto the live game pane. */
|
|
1005
|
+
function focusGameSurface(): void {
|
|
1006
|
+
const take = (): void => {
|
|
1007
|
+
const pane = editorHost().workspace.liveDocument.container();
|
|
1008
|
+
if (!pane) return;
|
|
1009
|
+
// The transport button keeps focus through a plain `pane.focus()` when
|
|
1010
|
+
// the pane is not yet focusable or the state flip re-renders it —
|
|
1011
|
+
// measured on preview build 72: activeElement stayed BUTTON[play-control]
|
|
1012
|
+
// and Space still stopped play. Drop the button's focus explicitly, make
|
|
1013
|
+
// the pane focusable, then focus it.
|
|
1014
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
1015
|
+
if (active instanceof HTMLElement && active !== pane && active.tagName === 'BUTTON') {
|
|
1016
|
+
active.blur();
|
|
1017
|
+
}
|
|
1018
|
+
if (!pane.hasAttribute('tabindex')) pane.tabIndex = -1;
|
|
1019
|
+
try {
|
|
1020
|
+
pane.focus({ preventScroll: true });
|
|
1021
|
+
} catch {
|
|
1022
|
+
// a detached pane cannot take focus; the scope hand-off below still stands
|
|
1023
|
+
}
|
|
1024
|
+
setActiveScope('viewport');
|
|
1025
|
+
};
|
|
1026
|
+
take();
|
|
1027
|
+
// The play-state flip re-renders the Game document; the pane the first
|
|
1028
|
+
// attempt focused may be replaced by the commit. Take it again after it.
|
|
1029
|
+
setTimeout(take, 150);
|
|
1030
|
+
setTimeout(take, 600);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Bind the play-mode orchestrator to editor context. Call once at init. */
|
|
1034
|
+
/**
|
|
1035
|
+
* Play binds to the shell store on its arrival (`shell-store-door.ts`), so no
|
|
1036
|
+
* workspace panel names Play to hand it the store; the authored viewport is
|
|
1037
|
+
* read through `viewport-door.ts` when a session presents its roots.
|
|
1038
|
+
*/
|
|
1039
|
+
onShellStore((store) => bindPlayMode(store));
|
|
1040
|
+
|
|
1041
|
+
export function bindPlayMode(store: EditorShellStore): void {
|
|
1042
|
+
_ctx = { store, gameContainer: null! }; // set dynamically from the live document's container
|
|
1043
|
+
// THE BARE-KEY YIELD IS THE FRAME'S. While Play runs the game's keys are the
|
|
1044
|
+
// game's, and what decides is the workbench: our stage actions carry a
|
|
1045
|
+
// `when` clause over `vgai.stage.focused`/`vgai.play`, so a bare key reaches
|
|
1046
|
+
// the game rather than a shell binding, and a ⌘-chord stays the workbench's.
|
|
1047
|
+
// The engine's own `InputManager` gate tracks the same predicate
|
|
1048
|
+
// independently (`gated-globals.ts` and `surface-keyboard.ts`), which is what
|
|
1049
|
+
// keeps a game's raw `window.addEventListener('keydown')` gated too.
|
|
1050
|
+
// The idle auto-stop needs a way to end play without `play-recording.ts`
|
|
1051
|
+
// importing the play lifecycle it is driven BY. Handed over here rather than
|
|
1052
|
+
// at module scope so the two directions of the edge stay one-way.
|
|
1053
|
+
bindPlayRecordingStop(exitPlayMode);
|
|
1054
|
+
for (const bound of playModeBindingWaiters) bound();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** True if play mode is currently active (playing or paused). */
|
|
1058
|
+
export function isPlayModeActive(): boolean {
|
|
1059
|
+
return _instance.session !== null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Narrow relay accessor — the live play session's `InputManager`(s)
|
|
1064
|
+
* and the Game root's loop, for `command-listener.ts`'s
|
|
1065
|
+
* `inject-input`/`set-time-scale`/`set-seed` cases. This is exactly the
|
|
1066
|
+
* accessor the vgai-sdk honest-gap jsdocs prescribed
|
|
1067
|
+
* (`play/input-operations.ts`, `play/control-operations.ts`): `_instance.session` is
|
|
1068
|
+
* module-private, so the relay needs this one exported read. Everything else
|
|
1069
|
+
* the relay reads (state providers, debug commands) flows through
|
|
1070
|
+
* `setActiveSystems`/`getActiveSystems` instead.
|
|
1071
|
+
*
|
|
1072
|
+
* `getInputTarget(worldId?)` (D15/T-D15.5 — review objection 2's fix)
|
|
1073
|
+
* REPLACES what used to be a plain `input: Game['input'] | null` field —
|
|
1074
|
+
* `Game.input` always resolves to the DEFAULT world's `InputManager` only,
|
|
1075
|
+
* while the debug bridge's `window.__vgai.input.*` reached whichever world's
|
|
1076
|
+
* `InputManager` last called `DebugRegistry.setVirtualInputTarget` (a
|
|
1077
|
+
* SEPARATE, last-writer-wins slot). In a multi-world project those two could
|
|
1078
|
+
* name DIFFERENT roots — the exact closed-PR review objection. Now both
|
|
1079
|
+
* doors call the SAME `getDebugRegistry(game).getVirtualInputTarget(worldId)`
|
|
1080
|
+
* — one resolution function (`debug-registry.ts`'s `resolveInputRootId`),
|
|
1081
|
+
* so `inject-input` (this accessor) and `window.__vgai.input.*`
|
|
1082
|
+
* (`debug-bridge.ts`) can never disagree about which world an unqualified
|
|
1083
|
+
* actuation targets again. `null` when nothing is registered for the
|
|
1084
|
+
* resolved id (or no `Game` is running at all); throws the registry's own
|
|
1085
|
+
* `DEBUG_INPUT_WORLD_NOT_FOUND` for an explicit, unregistered `worldId`.
|
|
1086
|
+
*
|
|
1087
|
+
* `runTicks` (D15/T-D15.4) is added the SAME way: reached via
|
|
1088
|
+
* `getDebugRegistry(game).getRunTicksTarget()` — the identical accessor
|
|
1089
|
+
* `runtime/debug-bridge.ts`'s `window.__vgai.runTicks` (door a) goes
|
|
1090
|
+
* through, so the editor relay's `run-ticks` case (door b, → `play.runTicks`)
|
|
1091
|
+
* calls byte-identical behavior (D17). `null` only when no `Game` is running
|
|
1092
|
+
* at all (`_instance.session` is `null`) — a live `Game` always wires a run-ticks
|
|
1093
|
+
* target immediately at construction (`createGame`, `runtime/game.ts`), so
|
|
1094
|
+
* `runTicks` is non-null whenever `_instance.session` is non-null. `runTicks` itself
|
|
1095
|
+
* is game-global (one shared tick loop across every world by design), so —
|
|
1096
|
+
* unlike the input target — it never needed per-world routing.
|
|
1097
|
+
*
|
|
1098
|
+
* `random`/`determinismDeclared` (D15/T-D15.6) back the `set-seed` relay
|
|
1099
|
+
* case and `collectState`'s `seed`/`deterministic` fields: `random` is
|
|
1100
|
+
* `getSeededRandom(game)` — the SAME game-scoped `SeededRandom` every
|
|
1101
|
+
* world's `ctx.random` reads, non-null for every real `createGame` call
|
|
1102
|
+
* (see `seeded-random.ts`) — and `determinismDeclared` is
|
|
1103
|
+
* `_instance.determinismDeclared`, set from the CURRENT session's manifest in
|
|
1104
|
+
* `enterPlayMode`.
|
|
1105
|
+
*/
|
|
1106
|
+
export function getPlayRuntimeAccess(): {
|
|
1107
|
+
getInputTarget(worldId?: string): DebugVirtualInputTarget | null;
|
|
1108
|
+
loop: GameLoop;
|
|
1109
|
+
runTicks: ((n: number, opts?: RunTicksOptions) => void) | null;
|
|
1110
|
+
/** The SETTLED-AWARE driver over the same target (`runtime/run-ticks-settled.ts`) — the
|
|
1111
|
+
* relay's `run-ticks` case awaits this so a tick never races a scene remount's async
|
|
1112
|
+
* commit, byte-identical with the bridge's `runTicksSettled` door (D17). */
|
|
1113
|
+
runTicksSettled: ((n: number, opts?: RunTicksOptions) => Promise<void>) | null;
|
|
1114
|
+
random: SeededRandom | null;
|
|
1115
|
+
determinismDeclared: boolean;
|
|
1116
|
+
} | null {
|
|
1117
|
+
const game = _instance.session?.game;
|
|
1118
|
+
if (!game) return null;
|
|
1119
|
+
const registry = getDebugRegistry(game);
|
|
1120
|
+
const runTicksTarget = registry?.getRunTicksTarget() ?? null;
|
|
1121
|
+
return {
|
|
1122
|
+
getInputTarget: (worldId?: string) => registry?.getVirtualInputTarget(worldId) ?? null,
|
|
1123
|
+
loop: game.loop,
|
|
1124
|
+
runTicks: runTicksTarget ? runTicksTarget.runTicks.bind(runTicksTarget) : null,
|
|
1125
|
+
runTicksSettled:
|
|
1126
|
+
registry && runTicksTarget ? (n, opts) => runTicksWhenSettled(registry, n, opts) : null,
|
|
1127
|
+
random: getSeededRandom(game),
|
|
1128
|
+
determinismDeclared: _instance.determinismDeclared,
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Get the running game's scene (for editor Scene tab rendering). */
|
|
1133
|
+
export function getGameScene(): THREE.Scene | null {
|
|
1134
|
+
const mounted = _instance.session?.game.defaultRoot.mounted;
|
|
1135
|
+
return mounted?.kind === 'three' ? mounted.scene : null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export interface GameRootSurfaceFact {
|
|
1139
|
+
readonly rootId: string;
|
|
1140
|
+
readonly kind: 'three' | 'canvas' | 'dom';
|
|
1141
|
+
readonly hasRenderableContent: boolean;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Exact mounted-root content facts for the Game document's empty-state UI. */
|
|
1145
|
+
export function gameRootSurfaceFacts(): readonly GameRootSurfaceFact[] {
|
|
1146
|
+
const roots = _instance.session?.game?.roots;
|
|
1147
|
+
if (!roots) return [];
|
|
1148
|
+
return roots.map((root) => {
|
|
1149
|
+
const mounted = root.mounted;
|
|
1150
|
+
let hasRenderableContent = false;
|
|
1151
|
+
if (mounted.kind === 'three') {
|
|
1152
|
+
hasRenderableContent = threeSceneHasRenderableContent(mounted.scene);
|
|
1153
|
+
} else if (mounted.kind === 'canvas') {
|
|
1154
|
+
try {
|
|
1155
|
+
hasRenderableContent =
|
|
1156
|
+
mounted.substrate.name === 'pixi'
|
|
1157
|
+
? mountedStoryHasPixiContent(mounted.substrate.root as import('pixi.js').Container)
|
|
1158
|
+
: mounted.substrate.name === 'babylon'
|
|
1159
|
+
? (
|
|
1160
|
+
(mounted.substrate.root as { scenes?: Array<{ rootNodes?: unknown[] }> })
|
|
1161
|
+
.scenes ?? []
|
|
1162
|
+
).some((scene) => (scene.rootNodes?.length ?? 0) > 0)
|
|
1163
|
+
: false;
|
|
1164
|
+
} catch {
|
|
1165
|
+
hasRenderableContent = false;
|
|
1166
|
+
}
|
|
1167
|
+
} else {
|
|
1168
|
+
hasRenderableContent = domHasRenderableContent(mounted.container);
|
|
1169
|
+
}
|
|
1170
|
+
return { rootId: root.id, kind: mounted.kind, hasRenderableContent };
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* #140 — the live play-mode game canvas, for `command-listener.ts`'s
|
|
1176
|
+
* `bridge-screenshot` relay op (`RelayTransport.screenshot` in `@volter/editor-live`).
|
|
1177
|
+
* The universal host mounts its root surfaces into `editorHost().workspace.liveDocument.container()`;
|
|
1178
|
+
* querying it for the bottom canvas avoids a surface-specific session alias.
|
|
1179
|
+
* `null` when not in play mode or no canvas surface is mounted.
|
|
1180
|
+
*/
|
|
1181
|
+
export function getPlayCanvas(): HTMLCanvasElement | null {
|
|
1182
|
+
if (!_instance.session) return null;
|
|
1183
|
+
// DECLARATION FIRST (ARCHITECTURE-CORE §The editor protocol, zero
|
|
1184
|
+
// inference). The roots path stamps each surface with the root it presents
|
|
1185
|
+
// (`create-runtime.ts`), and a self-booting game may name its own canvas on
|
|
1186
|
+
// its contract — so `presentationSurface` READS which canvas is the game's
|
|
1187
|
+
// picture. "Bottom-most `<canvas>` in DOM order" survives as its measured
|
|
1188
|
+
// fallback, unchanged, for a container carrying neither declaration.
|
|
1189
|
+
return presentationSurface(editorHost().workspace.liveDocument.container()).canvas;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** The game container for a SPECIFIC instance — the primary when `id` is omitted
|
|
1193
|
+
* (or names it), else the addressed additional seat. This is what per-instance
|
|
1194
|
+
* screenshot capture targets, so `game.instance(id).screenshot()` grabs THAT
|
|
1195
|
+
* seat's game stack and a plain screenshot can capture every seat in turn. */
|
|
1196
|
+
export function getInstanceContainer(id?: string): HTMLElement | null {
|
|
1197
|
+
if (!id || id === _instance.id) return editorHost().workspace.liveDocument.container();
|
|
1198
|
+
return _additional.find((inst) => inst.id === id)?.container ?? null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** The canvas for a SPECIFIC instance — the fallback capture leg when the
|
|
1202
|
+
* composite path is unavailable. Primary when `id` is omitted/names it. */
|
|
1203
|
+
export function getInstanceCanvas(id?: string): HTMLCanvasElement | null {
|
|
1204
|
+
if (!id || id === _instance.id) return getPlayCanvas();
|
|
1205
|
+
const inst = _additional.find((i) => i.id === id);
|
|
1206
|
+
if (!inst?.session) return null;
|
|
1207
|
+
// Same declaration-first read as the primary seat's — an addressed seat is a
|
|
1208
|
+
// second mount of the SAME project, so it carries the same stamps.
|
|
1209
|
+
return presentationSurface(inst.container).canvas;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** #146 — when the most recent play run began (ms epoch), or `null` before
|
|
1213
|
+
* any run in this page. `command-listener.ts`'s relay `snapshot` uses this to
|
|
1214
|
+
* fence `pageErrors` to errors from that run (the editor console accumulates
|
|
1215
|
+
* across runs; a session client reading "what went wrong during my run" must not
|
|
1216
|
+
* see an OLDER session's stale failures). PD-1: it deliberately survives
|
|
1217
|
+
* `exitPlayMode()` — a failed run's errors must still be readable after the
|
|
1218
|
+
* failure tears the run down, which is the only moment anyone asks. */
|
|
1219
|
+
export function getPlayStartedAt(): number | null {
|
|
1220
|
+
return _playStartedAtMs;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** When the most recent play run's teardown finished (ms epoch), or `null`
|
|
1224
|
+
* while a run is live / before the first run. Together with
|
|
1225
|
+
* `getPlayStartedAt()` this is the CLOSED window `command-listener.ts` uses to
|
|
1226
|
+
* split "this run's errors" (`pageErrors`/`consoleErrors`) from "the rest of
|
|
1227
|
+
* the session's" (`sessionErrors`/`sessionWarnings`) — see `_playEndedAtMs`. */
|
|
1228
|
+
export function getPlayEndedAt(): number | null {
|
|
1229
|
+
return _playEndedAtMs;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Every live instance (primary first). Lifecycle operations that act on "the
|
|
1233
|
+
* running game" — resize, pause, resume, step — must cover the WHOLE split, or
|
|
1234
|
+
* the extra seats keep running while the primary freezes, ignore resizes, and
|
|
1235
|
+
* so on. Single-instance play is just the one-element case. */
|
|
1236
|
+
function allLiveInstances(): PlayInstance[] {
|
|
1237
|
+
return [_instance, ..._additional].filter((inst) => inst.session);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Suspending presentation parks the RAF, not the session or its user-selected
|
|
1241
|
+
// Play/Pause state. Resume only loops this gate stopped, with start() resetting
|
|
1242
|
+
// the frame clock so hidden time never becomes a simulation catch-up burst.
|
|
1243
|
+
const presentationSuspendedLoops = new Set<GameLoop>();
|
|
1244
|
+
function syncPlayPresentationActivity(): void {
|
|
1245
|
+
const liveLoops = new Set(allLiveInstances().map((inst) => inst.session!.game.loop));
|
|
1246
|
+
for (const loop of presentationSuspendedLoops) {
|
|
1247
|
+
if (!liveLoops.has(loop)) presentationSuspendedLoops.delete(loop);
|
|
1248
|
+
}
|
|
1249
|
+
for (const loop of liveLoops) {
|
|
1250
|
+
if (isEditorPresentationActive()) {
|
|
1251
|
+
if (presentationSuspendedLoops.delete(loop)) loop.start();
|
|
1252
|
+
} else if (loop.liveness !== 'stopped') {
|
|
1253
|
+
presentationSuspendedLoops.add(loop);
|
|
1254
|
+
loop.stop();
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const unsubscribePlayPresentation = subscribeEditorPresentationActivity(
|
|
1259
|
+
syncPlayPresentationActivity,
|
|
1260
|
+
);
|
|
1261
|
+
import.meta.hot?.dispose(unsubscribePlayPresentation);
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The seat the editor's RUNTIME INSTRUMENTS are pointed at — the Inspect
|
|
1265
|
+
* selector's answer (`active-systems.ts`), never editor authoring focus and
|
|
1266
|
+
* never the addressed-instance wire. A stale/unset selection falls back to the
|
|
1267
|
+
* first live seat, mirroring `resolvedInspectedInstanceId`, so the instruments
|
|
1268
|
+
* are never orphaned.
|
|
1269
|
+
*/
|
|
1270
|
+
function inspectedInstance(): PlayInstance | null {
|
|
1271
|
+
const live = allLiveInstances();
|
|
1272
|
+
const id = inspectedInstanceId();
|
|
1273
|
+
return live.find((inst) => inst.id === id) ?? live[0] ?? null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The two things play publishes for the LIFETIME OF ONE SESSION: the play
|
|
1278
|
+
* surface's inspection subject (`inspection/game-subject.ts`), and the live
|
|
1279
|
+
* game every project contribution's props carry
|
|
1280
|
+
* (`tool-contribution-play.ts`).
|
|
1281
|
+
*
|
|
1282
|
+
* RESOURCE OWNERSHIP: play owns both outright. They are created by the one
|
|
1283
|
+
* `enterPlayMode` that adopts a session and dropped by the one `exitPlayMode`
|
|
1284
|
+
* that ends it — additional seats never publish their own, because the subject
|
|
1285
|
+
* is THE game and which seat it reads is resolved LIVE from the inspected
|
|
1286
|
+
* instance, on every read.
|
|
1287
|
+
*
|
|
1288
|
+
* Both are published as READERS rather than values for that reason: switching
|
|
1289
|
+
* the Inspect selector must move the subject and the contribution props
|
|
1290
|
+
* together, without a republish.
|
|
1291
|
+
*/
|
|
1292
|
+
let _gameSubjectRegistration: (() => void) | null = null;
|
|
1293
|
+
|
|
1294
|
+
function publishPlaySessionReaders(): void {
|
|
1295
|
+
_gameSubjectRegistration?.();
|
|
1296
|
+
_gameSubjectRegistration = registerGameNullSubject(() => ({
|
|
1297
|
+
projectName: getCurrentProject()?.config.name ?? null,
|
|
1298
|
+
// Only when a split makes "which of these games?" a real question.
|
|
1299
|
+
instanceName: allLiveInstances().length > 1 ? (inspectedInstance()?.name ?? null) : null,
|
|
1300
|
+
}));
|
|
1301
|
+
publishToolContributionPlay(() => {
|
|
1302
|
+
const inst = inspectedInstance();
|
|
1303
|
+
return inst?.session
|
|
1304
|
+
? { game: inst.session.game, instanceId: inst.id, recording: toolContributionRecording }
|
|
1305
|
+
: null;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function dropPlaySessionReaders(): void {
|
|
1310
|
+
_gameSubjectRegistration?.();
|
|
1311
|
+
_gameSubjectRegistration = null;
|
|
1312
|
+
publishToolContributionPlay(null);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Resize the running game to a specific resolution. `pixelRatio` (W2c
|
|
1316
|
+
* device preview) optionally re-pins the renderers' DPR in the same pass.
|
|
1317
|
+
* Applies to EVERY seat — under an even split every viewport is the same slot
|
|
1318
|
+
* size, so the extra canvases resize with the primary instead of staying
|
|
1319
|
+
* pinned at their mount size. */
|
|
1320
|
+
export function resizeGame(width: number, height: number, pixelRatio?: number): void {
|
|
1321
|
+
for (const inst of allLiveInstances()) inst.session?.resize(width, height, pixelRatio);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Serializes every `enterPlayMode()` invocation (the ghost-runtime bug:
|
|
1326
|
+
* `vgai play` on an already-playing/still-booting session left the
|
|
1327
|
+
* PREVIOUS runtime alive, ticking and rendering alongside the new one).
|
|
1328
|
+
*
|
|
1329
|
+
* Root cause: `enterPlayModeInner`'s own `if (_instance.session) exitPlayMode()`
|
|
1330
|
+
* guard (below) only protects the case where a PRIOR play has already fully
|
|
1331
|
+
* finished booting and assigned `_instance.session`. While a call is still mid-boot
|
|
1332
|
+
* (between this function being entered and `_instance.session` being assigned —
|
|
1333
|
+
* awaiting script loads / manifest resolution / asset loads / mount),
|
|
1334
|
+
* `_instance.session` reads as `null`, so a SECOND `enterPlayMode()` call landing in
|
|
1335
|
+
* that window sees no session to tear down and proceeds to run its own
|
|
1336
|
+
* entire prologue (`patchConsole()`, `startLogSession()`, canvas creation,
|
|
1337
|
+
* script loading, mount) CONCURRENTLY with the first call's. Both calls
|
|
1338
|
+
* mutate shared module-level/global state with no reentrancy guard —
|
|
1339
|
+
* `patchConsole()`/`unpatchConsole()` are the sharpest example (proven by a
|
|
1340
|
+
* red-before-green regression test: two overlapping calls double-wrap
|
|
1341
|
+
* `console.log` and the inner wrapper recurses into itself via the shared
|
|
1342
|
+
* `_originalConsoleLog` variable, a real `RangeError: Maximum call stack
|
|
1343
|
+
* size exceeded` — not a hypothetical). In the field this window is entered
|
|
1344
|
+
* whenever a caller re-issues `play` before the browser has acked the first
|
|
1345
|
+
* (a slow scene boot outliving the relay's/SDK's own `play.start` timeout is
|
|
1346
|
+
* the documented trigger — `packages/vgai-sdk/src/play/transport.ts`'s
|
|
1347
|
+
* `PLAY_START_TIMEOUT_MS`/`editor-server.ts`'s `PLAY_COMMAND_TIMEOUT_MS` —
|
|
1348
|
+
* and `play.start`'s own contract is explicitly "start (or restart)", so a
|
|
1349
|
+
* caller retrying after a timeout is using the API as documented, not
|
|
1350
|
+
* misusing it), or whenever the SSE `editor-command` listener
|
|
1351
|
+
* (`command-listener.ts`) dispatches a burst of commands without awaiting
|
|
1352
|
+
* the previous `handleCommand()` to settle.
|
|
1353
|
+
*
|
|
1354
|
+
* The existing `_playEpoch` generation guard decides, AFTER THE FACT, which
|
|
1355
|
+
* of two overlapping boots gets adopted into `_instance.session` and stops the loser
|
|
1356
|
+
* — but it does nothing to stop both boots from RUNNING (and their
|
|
1357
|
+
* prologues from clobbering each other) in the first place. Epoch-checking
|
|
1358
|
+
* is an adjudication mechanism, not a mutex.
|
|
1359
|
+
*
|
|
1360
|
+
* The fix is a FIFO queue, not a smarter epoch check: every call chains onto
|
|
1361
|
+
* the tail of `_enterQueue`, so a second call's ENTIRE body — prologue
|
|
1362
|
+
* included — never starts until the first call's entire invocation (success
|
|
1363
|
+
* or failure, including any self-teardown it performs) has fully settled.
|
|
1364
|
+
* This makes "play again while already playing/booting" a genuine, clean,
|
|
1365
|
+
* awaited restart for every caller (CLI, SDK, UI Play button, HMR's warm
|
|
1366
|
+
* restart) with no code path capable of leaving a booted session
|
|
1367
|
+
* unreferenced ("orphaned") — by the time any enterPlayMode() body runs,
|
|
1368
|
+
* it is provably the only one running.
|
|
1369
|
+
*/
|
|
1370
|
+
let _enterQueue: Promise<void> = Promise.resolve();
|
|
1371
|
+
let _activePlaytest: PlaytestContext | null = null;
|
|
1372
|
+
|
|
1373
|
+
/** Host remount of a native swap-slot scene — the entrypoint's selection
|
|
1374
|
+
* const is rewritten at serve time for THIS play run only. */
|
|
1375
|
+
export type PlaySelectionOverride = EntrypointSelectionOverride & {
|
|
1376
|
+
readonly regionId: string;
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
let _playSelectionOverride: PlaySelectionOverride | null = null;
|
|
1380
|
+
|
|
1381
|
+
export function activePlaytest(): PlaytestContext | null {
|
|
1382
|
+
return _activePlaytest;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function privatePlaytest(): PlaytestContext {
|
|
1386
|
+
const id = globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`;
|
|
1387
|
+
return {
|
|
1388
|
+
mode: 'private',
|
|
1389
|
+
id,
|
|
1390
|
+
roomKey: `private:${id}`,
|
|
1391
|
+
revision: null,
|
|
1392
|
+
participantId: EDITOR_PARTICIPANT_ID,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
export function enterPlayMode(
|
|
1397
|
+
explicitSeed?: number,
|
|
1398
|
+
playtest: PlaytestContext = privatePlaytest(),
|
|
1399
|
+
runName?: string | null,
|
|
1400
|
+
selectionOverride?: PlaySelectionOverride | null,
|
|
1401
|
+
): Promise<void> {
|
|
1402
|
+
const override = selectionOverride ?? null;
|
|
1403
|
+
const run = _enterQueue.then(() => {
|
|
1404
|
+
// Publish the boot's phases for as long as it runs, and clear them on
|
|
1405
|
+
// EVERY exit — success, throw, and the early returns that hand the run to
|
|
1406
|
+
// ingest/module mode. A phase left standing after a boot finished would
|
|
1407
|
+
// make the next stuck command blame a step that ended minutes ago.
|
|
1408
|
+
beginPlayBoot();
|
|
1409
|
+
return enterPlayModeInner(explicitSeed, playtest, runName ?? null, override).finally(() => {
|
|
1410
|
+
endPlayBoot();
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
// Advance the queue unconditionally so one caller's rejection can never
|
|
1414
|
+
// wedge every subsequent play attempt — each caller still observes its
|
|
1415
|
+
// OWN failure via the `run` promise this function returns.
|
|
1416
|
+
_enterQueue = run.then(
|
|
1417
|
+
() => undefined,
|
|
1418
|
+
() => undefined,
|
|
1419
|
+
);
|
|
1420
|
+
return run;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Let the editor's one auto-launch coordinator finish before generic Play
|
|
1425
|
+
* chooses a runtime. Returns true when that coordinator already owns the run.
|
|
1426
|
+
*/
|
|
1427
|
+
async function startAutoLaunchedAdapterPlay(store: EditorShellStore): Promise<boolean> {
|
|
1428
|
+
const { autoLaunchIngest } = await import('../ingest/mount-ingest-root');
|
|
1429
|
+
await autoLaunchIngest(store);
|
|
1430
|
+
const bootIngest = getIngestPlayControl();
|
|
1431
|
+
if (bootIngest) {
|
|
1432
|
+
bootIngest.play();
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
// `autoLaunchIngest` also owns the standalone `{ module }` route. If that
|
|
1436
|
+
// route claimed the project, its live module session is already the run.
|
|
1437
|
+
const { isModuleModeActive } = await import('../ingest/module-mode');
|
|
1438
|
+
return isModuleModeActive();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Enter play mode: create a game canvas, start game, disable editor controls.
|
|
1443
|
+
*
|
|
1444
|
+
* `explicitSeed` (D15/T-D15.6, objection-4 fix — `vgai play --seed <n>`)
|
|
1445
|
+
* — the CLI's `play` command relays it through as `cmd['seed']`
|
|
1446
|
+
* (`command-listener.ts`'s `'play'` case); it is the "explicit config"
|
|
1447
|
+
* leg of `resolveDeterminismSeed`'s precedence (highest — beats
|
|
1448
|
+
* `manifest.determinism.defaultSeed`/`?vgai-seed=` on the editor's own
|
|
1449
|
+
* page URL), threaded into whichever mount path this play resolves to
|
|
1450
|
+
* below, exactly like `mountManifestRoots`'s own `opts.seed` already is
|
|
1451
|
+
* for a standalone boot.
|
|
1452
|
+
*
|
|
1453
|
+
* `runName` (optional — `vgai play --name <text>`) is FINDABILITY and nothing
|
|
1454
|
+
* else: the server slugifies it into this run's `logs/play-*.jsonl` filename
|
|
1455
|
+
* and its session-journal line, so "the run where I tested the boss fight" is
|
|
1456
|
+
* a grep instead of timestamp archaeology. No registry, no uniqueness check —
|
|
1457
|
+
* two runs sharing a name are two files with different stamps.
|
|
1458
|
+
*
|
|
1459
|
+
* Not exported directly — always call {@link enterPlayMode}, which
|
|
1460
|
+
* serializes invocations of this function so overlapping callers can never
|
|
1461
|
+
* run concurrently. See that wrapper's doc comment for why.
|
|
1462
|
+
*/
|
|
1463
|
+
/**
|
|
1464
|
+
* Every network-shaped step of the boot below runs through this. See
|
|
1465
|
+
* `play-boot-stall.ts` for the measurement it exists for: on a BACKGROUNDED
|
|
1466
|
+
* tab these fetches are deprioritized by the browser and can sit for minutes,
|
|
1467
|
+
* which the relay could only report as a generic 120s "editor connected but
|
|
1468
|
+
* did not respond". The runtime MOUNT deliberately does not go through here —
|
|
1469
|
+
* an abandoned mount would leak a live session, and it is not the starved step.
|
|
1470
|
+
*/
|
|
1471
|
+
function playBootStep<T>(step: PlayBootPhase, work: Promise<T>): Promise<T> {
|
|
1472
|
+
// Publish the phase BEFORE the work — the whole point is that a step which
|
|
1473
|
+
// never returns is still named. See `play-boot-phase.ts`.
|
|
1474
|
+
markPlayBootPhase(step);
|
|
1475
|
+
return withPlayBootStallGuard(step, work, {
|
|
1476
|
+
isHidden: () => typeof document !== 'undefined' && document.hidden,
|
|
1477
|
+
editorUrl: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
async function enterPlayModeInner(
|
|
1482
|
+
explicitSeed: number | undefined,
|
|
1483
|
+
playtest: PlaytestContext,
|
|
1484
|
+
runName: string | null,
|
|
1485
|
+
selectionOverride: PlaySelectionOverride | null,
|
|
1486
|
+
): Promise<void> {
|
|
1487
|
+
// PD-1 — degrade loudly: every path out of this function that did NOT start
|
|
1488
|
+
// play now THROWS. A silent `return` resolved the caller's promise, so the
|
|
1489
|
+
// command relay answered `{ ok: true }` for a play that never started and
|
|
1490
|
+
// the CLI fell back to a generic "check logs/play-*.jsonl" that named no
|
|
1491
|
+
// cause. The three genuine-failure gates (no editor shell, no game
|
|
1492
|
+
// container, no project) throw here; the epoch checks below throw a
|
|
1493
|
+
// distinct "cancelled" message, because "someone stopped it" is also a real
|
|
1494
|
+
// answer and is not the same answer as "it broke".
|
|
1495
|
+
if (!_ctx) {
|
|
1496
|
+
const bindingEpoch = _playEpoch;
|
|
1497
|
+
markPlayBootPhase('waiting for the editor to bind Play');
|
|
1498
|
+
await waitForPlayModeBinding();
|
|
1499
|
+
if (_playEpoch !== bindingEpoch) {
|
|
1500
|
+
throw new Error('Play start cancelled while waiting for the editor to bind.');
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (!_ctx) throw new Error('Play mode failed to start: the editor shell is not bound.');
|
|
1504
|
+
// EVERY step below publishes itself before it runs (`play-boot-phase.ts`).
|
|
1505
|
+
// A play boot that stops answering is otherwise indistinguishable from a
|
|
1506
|
+
// healthy tab — measured at N=20000, three consecutive silent timeouts.
|
|
1507
|
+
// `enterPlayMode` owns the begin/end pair; this function owns the marks.
|
|
1508
|
+
// Play must never start while the boot-time project bootstrap is still in
|
|
1509
|
+
// flight. Resolves immediately when no bootstrap is pending; gating HERE
|
|
1510
|
+
// rather than in the Play button covers every caller — UI, `vgai play` relay,
|
|
1511
|
+
// SDK.
|
|
1512
|
+
markPlayBootPhase('waiting for the project bootstrap to settle');
|
|
1513
|
+
await projectBootstrapSettled();
|
|
1514
|
+
// The play button is only shown while stopped, so a non-null _instance.session here is
|
|
1515
|
+
// stale — a previous play that didn't fully tear down (e.g. a re-entry race).
|
|
1516
|
+
// Clean it up so re-entering play mode always works.
|
|
1517
|
+
if (_instance.session) exitPlayMode();
|
|
1518
|
+
// After the stale-session cleanup: exitPlayMode clears the override, and
|
|
1519
|
+
// THIS run's override must survive that. A later Play button (no override)
|
|
1520
|
+
// remounts the source-declared key.
|
|
1521
|
+
_playSelectionOverride = selectionOverride;
|
|
1522
|
+
// Project detection settling is not the same thing as the editor's
|
|
1523
|
+
// authoring/ingest bootstrap settling: the latter waits for document/story
|
|
1524
|
+
// installation and can arrive seconds later. Reuse its deduplicated promise
|
|
1525
|
+
// here so a fast ▶ cannot enter generic first-party Play while the actual
|
|
1526
|
+
// ingest root is still mounting, then have that late boot mount steal and
|
|
1527
|
+
// pause the session. This call is a no-op for a project with no ingest route.
|
|
1528
|
+
if (await startAutoLaunchedAdapterPlay(_ctx.store)) return;
|
|
1529
|
+
// An ingest root that already has Edit pieces (a named world component, or
|
|
1530
|
+
// isolation-document scene tabs) holds its live mount back until here
|
|
1531
|
+
// (`ingest/deferred-ingest-play.ts`): Edit shows the constructs, and PLAY
|
|
1532
|
+
// is what constructs and runs the game. It mounts through the ordinary
|
|
1533
|
+
// ingest routes and owns the whole run, so this returns instead of also
|
|
1534
|
+
// booting a first-party composition.
|
|
1535
|
+
if (await mountDeferredIngestForPlay(_ctx.store)) return;
|
|
1536
|
+
// Capture this play's generation AFTER the stale-session cleanup (which
|
|
1537
|
+
// bumps the epoch via exitPlayMode). Any exitPlayMode — or a newer
|
|
1538
|
+
// enterPlayMode — during the async boot below invalidates this generation.
|
|
1539
|
+
const epoch = ++_playEpoch;
|
|
1540
|
+
_activePlaytest = playtest;
|
|
1541
|
+
// #146 — fence for the relay snapshot's `pageErrors` (see
|
|
1542
|
+
// `getPlayStartedAt`): errors logged before THIS play run are stale noise
|
|
1543
|
+
// to a probe reading "what went wrong during my run".
|
|
1544
|
+
_playStartedAtMs = Date.now();
|
|
1545
|
+
// A new run re-opens the window the previous exit closed.
|
|
1546
|
+
_playEndedAtMs = null;
|
|
1547
|
+
const { store } = _ctx;
|
|
1548
|
+
|
|
1549
|
+
// Nothing is pre-validated here: a TSX world root has no schema to check
|
|
1550
|
+
// against, so its failures surface as module/mount errors.
|
|
1551
|
+
|
|
1552
|
+
// Patch console → editorConsole tagged [game]
|
|
1553
|
+
patchConsole();
|
|
1554
|
+
|
|
1555
|
+
// Start log session and register sink for JSONL persistence. Start, every
|
|
1556
|
+
// flush, and end share ONE permanent promise tail. `exitPlayMode()` stays
|
|
1557
|
+
// synchronous for UI callers, but a restart's start is queued after the
|
|
1558
|
+
// prior run's final flush/end; otherwise that late end closes the NEW
|
|
1559
|
+
// server session and every later 200ms flush receives a 409. Keeping start
|
|
1560
|
+
// on the tail also handles Stop arriving while start itself is in flight:
|
|
1561
|
+
// Stop's end queues behind it, then the epoch check below prevents the
|
|
1562
|
+
// cancelled run from installing a sink/interval after Stop.
|
|
1563
|
+
const logSessionStart = _logFlushChain.then(() => startLogSession(runName));
|
|
1564
|
+
_logFlushChain = logSessionStart;
|
|
1565
|
+
await playBootStep('starting the play log session', logSessionStart);
|
|
1566
|
+
if (epoch !== _playEpoch) return;
|
|
1567
|
+
_pendingEntries = [];
|
|
1568
|
+
_lastPersistedDebugEventSeq = 0;
|
|
1569
|
+
editorConsole.setSink((entry: ConsoleEntry) => {
|
|
1570
|
+
// Play-log half: stamp {tick, simT} from the live
|
|
1571
|
+
// session's built-in `time` provider so log entries correlate
|
|
1572
|
+
// frame-exactly with `ctx.debug.emit` events. Best-effort — entries
|
|
1573
|
+
// logged before the session finishes booting (or after stop) carry no
|
|
1574
|
+
// stamp, and a throwing read must never break logging.
|
|
1575
|
+
let stamp: { tick: number; simT: number } | undefined;
|
|
1576
|
+
try {
|
|
1577
|
+
const time = _instance.session?.game?.systemAdapters.debug?.state('time') as
|
|
1578
|
+
| { tick?: number; simSeconds?: number }
|
|
1579
|
+
| undefined;
|
|
1580
|
+
if (typeof time?.tick === 'number' && typeof time.simSeconds === 'number') {
|
|
1581
|
+
stamp = { tick: time.tick, simT: time.simSeconds };
|
|
1582
|
+
}
|
|
1583
|
+
} catch {
|
|
1584
|
+
/* unstampable — keep the entry */
|
|
1585
|
+
}
|
|
1586
|
+
_pendingEntries.push({
|
|
1587
|
+
t: entry.timestamp,
|
|
1588
|
+
level: entry.level,
|
|
1589
|
+
msg: entry.message,
|
|
1590
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
1591
|
+
...(entry.subsystem ? { sub: entry.subsystem } : {}),
|
|
1592
|
+
...(entry.metadata ? { meta: entry.metadata } : {}),
|
|
1593
|
+
...(stamp ?? {}),
|
|
1594
|
+
// The run's world/time-scale, when this writer genuinely knows them.
|
|
1595
|
+
...playLogRunStamp(),
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
_flushInterval = setInterval(() => {
|
|
1599
|
+
drainDebugEventsToPlayLog();
|
|
1600
|
+
if (_pendingEntries.length > 0) {
|
|
1601
|
+
const batch = _pendingEntries.splice(0);
|
|
1602
|
+
_logFlushChain = _logFlushChain.then(() => flushLogEntries(batch));
|
|
1603
|
+
}
|
|
1604
|
+
}, 200);
|
|
1605
|
+
|
|
1606
|
+
// Register Escape → stop play mode
|
|
1607
|
+
_escapeListener = (e: KeyboardEvent) => {
|
|
1608
|
+
if (e.key === 'Escape' && shouldEscapeStopPlay(e)) exitPlayMode();
|
|
1609
|
+
};
|
|
1610
|
+
// Bubble phase (default, no `capture`) is load-bearing: it lets overlay
|
|
1611
|
+
// handlers (menus/dialogs/popovers) run first and preventDefault() the
|
|
1612
|
+
// event before shouldEscapeStopPlay sees it.
|
|
1613
|
+
window.addEventListener('keydown', _escapeListener);
|
|
1614
|
+
|
|
1615
|
+
// Create game canvas inside the dedicated game container. While the game is
|
|
1616
|
+
// not running there IS no game viewport, so the document that owns that
|
|
1617
|
+
// container is installed here — the first thing this play does that the
|
|
1618
|
+
// author can see — and this waits for its panel to commit.
|
|
1619
|
+
markPlayBootPhase('opening the Game document');
|
|
1620
|
+
await editorHost().workspace.liveDocument.acquire();
|
|
1621
|
+
if (epoch !== _playEpoch) return;
|
|
1622
|
+
const gameContainer = editorHost().workspace.liveDocument.container();
|
|
1623
|
+
if (!gameContainer) {
|
|
1624
|
+
const msg = 'Play mode failed to start: the game container is not mounted in the workspace.';
|
|
1625
|
+
editorConsole.error(msg, 'play-mode');
|
|
1626
|
+
// Tear down the console patch + flush interval + escape listener set up
|
|
1627
|
+
// above, same as the other failure paths (ED8).
|
|
1628
|
+
exitPlayMode();
|
|
1629
|
+
// PD-1: throw, don't return — a bare return resolved the caller's promise
|
|
1630
|
+
// and the relay acked a play that never started.
|
|
1631
|
+
throw new Error(msg);
|
|
1632
|
+
}
|
|
1633
|
+
const w = gameContainer.clientWidth;
|
|
1634
|
+
const h = gameContainer.clientHeight;
|
|
1635
|
+
// First-party play is the same page-shaped problem ingest already solved:
|
|
1636
|
+
// a canvas game that does `document.body.appendChild(overlay)` with
|
|
1637
|
+
// `position:fixed;inset:0` (floor-sim's F1 dock, its html/body sheet)
|
|
1638
|
+
// would cover the editor. Hand it this pane as its page before any
|
|
1639
|
+
// project module evaluates — `game-realm-page.ts` reasons 1 and 2.
|
|
1640
|
+
if (!gameContainer.style.contain) {
|
|
1641
|
+
gameContainer.style.cssText += GAME_SURFACE_CONTAINMENT_CSS;
|
|
1642
|
+
}
|
|
1643
|
+
markGameCssScope(gameContainer);
|
|
1644
|
+
setGameSurface(gameContainer);
|
|
1645
|
+
|
|
1646
|
+
// The host's live transition (chrome dissolve, camera flight) — before the
|
|
1647
|
+
// play state flips; every exit path funnels through endPlayTransition.
|
|
1648
|
+
editorHost().viewport.transition.begin();
|
|
1649
|
+
|
|
1650
|
+
// setPlayState auto-switches to Game tab
|
|
1651
|
+
store.setPlayState('playing');
|
|
1652
|
+
// THE GAME TAKES THE KEYBOARD AS PLAY STARTS. Until the player clicks the
|
|
1653
|
+
// pane, focus stays on the transport button they pressed and the hotkey
|
|
1654
|
+
// scope on the workspace — so the next Enter or Space activates that
|
|
1655
|
+
// button and STOPS play, and a redeploy Enter "does nothing" (runhuman
|
|
1656
|
+
// pass 145; traced on production build 70: keydown target BUTTON, play
|
|
1657
|
+
// ends). Focus the pane and hand the scope to the viewport now, the same
|
|
1658
|
+
// state a click into the game produces.
|
|
1659
|
+
focusGameSurface();
|
|
1660
|
+
editorConsole.log('Play mode started', 'play-mode');
|
|
1661
|
+
// Apply the project's adapter-declared utilities after transient Play chrome settles.
|
|
1662
|
+
editorHost().viewport.transition.onSettled(() => {
|
|
1663
|
+
if (epoch === _playEpoch && store.playState === 'playing') revealWorkspacePlayUtilities();
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// WHAT THIS BOOT CREATED, BEFORE ANYTHING ELSE CAN REACH IT. `_instance.id`
|
|
1667
|
+
// and `_instance.session` are assigned only once the mount AND the bindings
|
|
1668
|
+
// after it have all succeeded, so a failure anywhere earlier leaves
|
|
1669
|
+
// `exitPlayMode` holding an EMPTY id — and an empty id is exactly what
|
|
1670
|
+
// `disposeInstanceRealm` early-returns on, while a bare `clearGameSurface()`
|
|
1671
|
+
// resets the DEFAULT realm's page instead of this mount's. These two locals
|
|
1672
|
+
// are the handles the catch below needs to reclaim a half-built play.
|
|
1673
|
+
let bootMountId: string | null = null;
|
|
1674
|
+
let bootedSession: GameSession | null = null;
|
|
1675
|
+
try {
|
|
1676
|
+
const project = getCurrentProject();
|
|
1677
|
+
|
|
1678
|
+
if (!project) {
|
|
1679
|
+
// PD-1: throw (the catch below logs + rolls back exactly as it does for
|
|
1680
|
+
// every other boot failure) instead of returning — a bare return let the
|
|
1681
|
+
// relay ack a play that never started.
|
|
1682
|
+
throw new Error('No project open — open or create a project first');
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const manifest = await playBootStep('fetching the project manifest', fetchGameManifest());
|
|
1686
|
+
// Publish the exact roots BEFORE entry resolution/mounting. The Game
|
|
1687
|
+
// document is already visible at this point; without a waiting fact its
|
|
1688
|
+
// empty area cannot say which declared roots it is waiting for.
|
|
1689
|
+
_hostMountedReadyRootIds = declaredRoots(manifest).map((root) => root.id);
|
|
1690
|
+
for (const rootId of _hostMountedReadyRootIds) {
|
|
1691
|
+
recordRootReadiness({
|
|
1692
|
+
rootId,
|
|
1693
|
+
mechanism: 'host-mount',
|
|
1694
|
+
source: 'declared',
|
|
1695
|
+
state: 'waiting',
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
// Every project mounts through the same manifest composer.
|
|
1699
|
+
// Cardinality never selects a runtime, hierarchy, or persistence model.
|
|
1700
|
+
// Every root resolves from its own adapter-owned content.
|
|
1701
|
+
let session: GameSession;
|
|
1702
|
+
// PD-3 — open the cross-root module-split window BEFORE any root imports
|
|
1703
|
+
// its entry, and close it after every root has MOUNTED (a root that
|
|
1704
|
+
// imports lazily inside `mount()` has to be inside the window too). See
|
|
1705
|
+
// `project-module-split.ts` for why a mount-scoped window is the sound
|
|
1706
|
+
// way to count module instances.
|
|
1707
|
+
beginProjectModuleSplitWatch();
|
|
1708
|
+
const { entries, mountId } = await playBootStep(
|
|
1709
|
+
"resolving the project's root entries",
|
|
1710
|
+
resolveAllRootEntries(manifest, project.rootPath, {
|
|
1711
|
+
selectionOverride: selectionOverride ?? undefined,
|
|
1712
|
+
}),
|
|
1713
|
+
);
|
|
1714
|
+
editorConsole.log(
|
|
1715
|
+
`Resolved manifest composition (${declaredRoots(manifest).length} world${declaredRoots(manifest).length === 1 ? '' : 's'}): ` +
|
|
1716
|
+
declaredRoots(manifest)
|
|
1717
|
+
.map((w) => `${w.id} (${w.surface})`)
|
|
1718
|
+
.join(', '),
|
|
1719
|
+
'play-mode',
|
|
1720
|
+
);
|
|
1721
|
+
// Play may have been exited while resolving — don't boot a runtime for
|
|
1722
|
+
// a play that is already over.
|
|
1723
|
+
if (epoch !== _playEpoch) return;
|
|
1724
|
+
// Re-read the container's real size instead of the `w`/`h` captured
|
|
1725
|
+
// BEFORE `store.setPlayState('playing')` above (that capture can race
|
|
1726
|
+
// the Game-tab layout switch and read 0x0). This still matters for the
|
|
1727
|
+
// INITIAL render-buffer resolution (`mountManifestRoots`'s own
|
|
1728
|
+
// `width`/`height` default otherwise falls back to the manifest's
|
|
1729
|
+
// `resolution`) even though it is no longer the ONLY thing standing
|
|
1730
|
+
// between a 0x0 mount and a correctly-laid-out canvas: the roots-path
|
|
1731
|
+
// canvas's on-screen SIZE is now fully CSS-container-driven
|
|
1732
|
+
// (`create-runtime.ts`'s `mountOneThreeRoot`, E4.R1 reopen fix), not
|
|
1733
|
+
// pinned to whatever it mounted at.
|
|
1734
|
+
const rw = gameContainer.clientWidth || w;
|
|
1735
|
+
const rh = gameContainer.clientHeight || h;
|
|
1736
|
+
setGameSurface(gameContainer, mountId);
|
|
1737
|
+
// The realm under `mountId` exists from here on (this call created its
|
|
1738
|
+
// page, and every project module served under this id resolves through
|
|
1739
|
+
// it). Record it so a failure below can reclaim it — see the catch.
|
|
1740
|
+
bootMountId = mountId;
|
|
1741
|
+
// NOT a `playBootStep`: an abandoned mount would leak a live session, so
|
|
1742
|
+
// this step has no stall guard — which is exactly why it needs a phase.
|
|
1743
|
+
// MEASURED at N=20000: the block that ate three command budgets started
|
|
1744
|
+
// here and ran for 199.5s with nothing anywhere able to name it.
|
|
1745
|
+
markPlayBootPhase('mounting the runtime roots');
|
|
1746
|
+
const { mountManifestRoots } = await import('@volter/game-runtime/runtime/mount-manifest');
|
|
1747
|
+
session = await mountManifestRoots({
|
|
1748
|
+
manifest,
|
|
1749
|
+
container: gameContainer,
|
|
1750
|
+
entries,
|
|
1751
|
+
width: rw,
|
|
1752
|
+
height: rh,
|
|
1753
|
+
// D15/T-D15.6 — `vgai play --seed`'s explicit config leg; `undefined`
|
|
1754
|
+
// (the overwhelmingly common case) leaves `mountManifestRoots`'s own
|
|
1755
|
+
// manifest/`?vgai-seed=` precedence untouched.
|
|
1756
|
+
seed: explicitSeed,
|
|
1757
|
+
playtest,
|
|
1758
|
+
});
|
|
1759
|
+
bootedSession = session;
|
|
1760
|
+
|
|
1761
|
+
// Play was exited (Stop/Escape) — or re-entered — while the runtime was
|
|
1762
|
+
// booting. This session belongs to a play that is already over: stop it
|
|
1763
|
+
// and bail WITHOUT adopting its scene into the store. Adopting here left
|
|
1764
|
+
// the editor showing an empty hierarchy in edit mode and leaked the
|
|
1765
|
+
// session (RAF loop, Rapier world, WebGL context).
|
|
1766
|
+
if (epoch !== _playEpoch) {
|
|
1767
|
+
session.stop();
|
|
1768
|
+
disposeInstanceRealmAfterStop(session, mountId, 'Cancelled instance');
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
// PD-3 — close the window and report LOUDLY. A split does not stop the
|
|
1772
|
+
// game (both copies run; they just disagree), so this is an error-level
|
|
1773
|
+
// report rather than a throw: the failure mode being fixed is SILENCE,
|
|
1774
|
+
// not a crash. `collectState` carries the same reports to `vgai status`,
|
|
1775
|
+
// and `editorConsole.error` reaches the editor console panel and the
|
|
1776
|
+
// play-log sink the CLI reads back.
|
|
1777
|
+
for (const split of endProjectModuleSplitWatch(project.rootPath)) {
|
|
1778
|
+
editorConsole.error(formatProjectModuleSplitMessage(split), 'play-mode');
|
|
1779
|
+
}
|
|
1780
|
+
// READINESS, ANSWERED BY THE HOST. A host-mounted (exported-composition)
|
|
1781
|
+
// root needs no game code to state when it is ready: the host RAN the
|
|
1782
|
+
// mount, and `mountManifestRoots` resolving IS that answer — including the
|
|
1783
|
+
// roots' own async setup, which it awaits. So every one of these roots
|
|
1784
|
+
// reports `declared`, and no measured wait stands anywhere behind them
|
|
1785
|
+
// (`readiness.ts`; published as `vgai status`'s `readiness` facet).
|
|
1786
|
+
for (const rootId of _hostMountedReadyRootIds) {
|
|
1787
|
+
recordRootReadiness({ rootId, mechanism: 'host-mount', source: 'declared', state: 'ready' });
|
|
1788
|
+
}
|
|
1789
|
+
// The game has mounted its ordinary native roots. Only now does the
|
|
1790
|
+
// boundary project app-owned reads, commands and input onto the session
|
|
1791
|
+
// protocol; no component participated in host registration.
|
|
1792
|
+
installAdapterRuntimeBindings(session.game);
|
|
1793
|
+
_instance.session = session;
|
|
1794
|
+
_instance.id = mountId;
|
|
1795
|
+
_instance.journalSession = playJournal(mountId, '').session;
|
|
1796
|
+
_instance.name = instanceNameAt(0);
|
|
1797
|
+
_instance.unregisterPerformanceSource?.();
|
|
1798
|
+
_instance.unregisterPerformanceSource = registerPerformanceSource({
|
|
1799
|
+
id: 'workspace:game',
|
|
1800
|
+
label: 'Game runtime',
|
|
1801
|
+
kind: 'game',
|
|
1802
|
+
instanceId: mountId,
|
|
1803
|
+
profiler: session.game.profiler,
|
|
1804
|
+
});
|
|
1805
|
+
clearRestartRequired();
|
|
1806
|
+
_instance.determinismDeclared = manifest.determinism?.seededRandom === true;
|
|
1807
|
+
// D16 parity with the standalone mount path (`mount-manifest.ts`'s
|
|
1808
|
+
// `setRoomDeclared` wiring): a project whose manifest declares a Colyseus
|
|
1809
|
+
// room must declare `locus: 'client' | 'server'` on every debug command
|
|
1810
|
+
// it registers — in editor play mode exactly like on the standalone
|
|
1811
|
+
// page. Idempotent for the manifest composer (`mountManifestRoots`
|
|
1812
|
+
// already set it).
|
|
1813
|
+
if (manifest.configurations.some((c) => c.kind === 'process')) {
|
|
1814
|
+
getDebugRegistry(session.game)?.setRoomDeclared(true);
|
|
1815
|
+
}
|
|
1816
|
+
notifySessionListeners();
|
|
1817
|
+
// D19: play edits are session-local regardless of world count.
|
|
1818
|
+
store.setPlayEditRegime('ephemeral');
|
|
1819
|
+
|
|
1820
|
+
// Swap store to game scene so hierarchy/inspector show game entities.
|
|
1821
|
+
// liveHierarchy: first-party play mode is the ONLY adoption path that may
|
|
1822
|
+
// synthesize descriptors for untagged runtime objects (anti-shim rule —
|
|
1823
|
+
// ingest/module mounts adopt foreign scenes and must never fabricate).
|
|
1824
|
+
_instance.presentation = editorHost().viewport.presentRoots(session.game.roots);
|
|
1825
|
+
markPlayBootPhase('installing play authoring for each root');
|
|
1826
|
+
await installPlayRootAuthoring(
|
|
1827
|
+
store,
|
|
1828
|
+
session.game.roots,
|
|
1829
|
+
manifest,
|
|
1830
|
+
_instance.presentation?.worldId ?? null,
|
|
1831
|
+
() => epoch === _playEpoch,
|
|
1832
|
+
);
|
|
1833
|
+
// Stop/Escape can land while the authoring install above is in flight, and
|
|
1834
|
+
// `exitPlayMode` is synchronous: by the time this resumes it has already
|
|
1835
|
+
// stopped this session and run its whole teardown. Everything below binds
|
|
1836
|
+
// the editor TO that session — instrument addressing, the play-session
|
|
1837
|
+
// readers, the input gate, the store subscription — so without this guard a
|
|
1838
|
+
// Stop during boot re-installs all of it onto a game that no longer exists.
|
|
1839
|
+
// The Game inspection subject is the visible half: its whole lifetime
|
|
1840
|
+
// contract is "no runtime, no subject", and a registration published here
|
|
1841
|
+
// has no exit left to drop it. Same check the two boot awaits above make.
|
|
1842
|
+
// The install itself takes the same generation guard (it awaits too, and
|
|
1843
|
+
// used to overwrite the just-restored edit authoring on the way out), so
|
|
1844
|
+
// by the time this line runs there is genuinely nothing left to undo.
|
|
1845
|
+
if (epoch !== _playEpoch) return;
|
|
1846
|
+
|
|
1847
|
+
// Wire physics sync THROUGH the first-party `RapierPhysicsAdapter`:
|
|
1848
|
+
// the gizmo path freezes a Rapier-owned body on beginEdit, commits the edited
|
|
1849
|
+
// pose each frame (the callback below), and unfreezes on release — so the
|
|
1850
|
+
// postPhysics writer doesn't snap the gizmo edit back. The editor speaks only
|
|
1851
|
+
// the `PhysicsAdapter` interface, never Rapier directly.
|
|
1852
|
+
// Install the GAME-scoped System-adapter aggregate (§7.1-3, probe1) — every
|
|
1853
|
+
// world's `mounted.systems` merged (first registration wins per key), NOT
|
|
1854
|
+
// just the default world's own copy (`firstParty(session).systems`, the
|
|
1855
|
+
// pre-fix read) — so a networking/etc. adapter registered from ANY world's
|
|
1856
|
+
// setup is visible to the editor's gizmo path + inspector panels.
|
|
1857
|
+
markPlayBootPhase('binding the editor to the running game');
|
|
1858
|
+
const systems = session.game.systemAdapters;
|
|
1859
|
+
// Register UNDER THIS MOUNT'S ID. Until now every mount registered as the
|
|
1860
|
+
// anonymous solo instance, so `systemsForInstance` had exactly one thing
|
|
1861
|
+
// it could ever resolve and the id it resolves BY was never produced —
|
|
1862
|
+
// the addressing layer was a switchboard with nothing plugged in.
|
|
1863
|
+
setActiveSystems(systems, mountId);
|
|
1864
|
+
setInspectedInstance(mountId);
|
|
1865
|
+
publishPlaySessionReaders();
|
|
1866
|
+
_instance.unsubscribeSystemAdapters?.();
|
|
1867
|
+
_instance.unsubscribeSystemAdapters =
|
|
1868
|
+
session.game.subscribeSystemAdapters?.(() => {
|
|
1869
|
+
updateInstanceSystems(session.game.systemAdapters, mountId);
|
|
1870
|
+
}) ?? null;
|
|
1871
|
+
|
|
1872
|
+
// T6.3: gate game input (raw `window`/`document` listeners in game code via
|
|
1873
|
+
// gated-globals, AND the default world's first-party InputManager) to only
|
|
1874
|
+
// fire while play is actually running AND the Game tab is the focused
|
|
1875
|
+
// viewport — so keystrokes typed into the editor (Scene tab, inspector
|
|
1876
|
+
// fields) don't leak into the running game.
|
|
1877
|
+
// The gate is FOCUS-AWARE (`instanceInputActive`): input flows only while
|
|
1878
|
+
// play runs, the Game tab is active, AND this instance holds keyboard focus.
|
|
1879
|
+
// For a single instance focus is always the primary, so this is identical to
|
|
1880
|
+
// the pre-split behaviour; with a split, exactly the focused seat is live.
|
|
1881
|
+
// Same id, and that is the point: the realm this mount's project modules
|
|
1882
|
+
// resolve their gated `window`/`document` through is keyed by the mount id
|
|
1883
|
+
// baked into their own urls, so the gate has to be registered under it or
|
|
1884
|
+
// the modules find the default realm's gate instead of their own.
|
|
1885
|
+
setGameInputGate(() => instanceInputActive(mountId), mountId);
|
|
1886
|
+
// Sync EVERY live instance's first-party InputManager from the play/tab/
|
|
1887
|
+
// focus predicate whenever the store changes (a tab switch flips the active
|
|
1888
|
+
// viewport for all of them). `resyncInstanceInputs` resolves each
|
|
1889
|
+
// instance's `game.input` defensively — a session whose game handle has no
|
|
1890
|
+
// first-party InputManager (an ingest mount, a partial double) has only the
|
|
1891
|
+
// raw window/document gate above.
|
|
1892
|
+
resyncInstanceInputs();
|
|
1893
|
+
_unsubStore = store.subscribe(resyncInstanceInputs);
|
|
1894
|
+
|
|
1895
|
+
// Node-id keyed only: `setEcsSyncTransform` hands this an editor node id
|
|
1896
|
+
// and a THREE `Transform`, which a display-keyed carrier has no values for.
|
|
1897
|
+
const physics = nodeKeyedPhysics(systems.physics);
|
|
1898
|
+
store.setEcsSyncTransform((id, obj) => {
|
|
1899
|
+
// `commit` refuses an id this adapter cannot resolve rather than
|
|
1900
|
+
// returning as if the write landed — so ask before driving it.
|
|
1901
|
+
if (!physics || physics.ownerOf(id) === 'unresolved') return;
|
|
1902
|
+
physics.commit(id, {
|
|
1903
|
+
position: obj.position.toArray() as [number, number, number],
|
|
1904
|
+
rotation: obj.quaternion.toArray() as [number, number, number, number],
|
|
1905
|
+
scale: obj.scale.toArray() as [number, number, number],
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
// Container may have been display:none when w/h were read above.
|
|
1910
|
+
// Now that the session exists, do one resize with the actual dimensions.
|
|
1911
|
+
// W2c: a device preset chosen BEFORE play must land its emulated DPR on
|
|
1912
|
+
// the fresh session too (mount pins `min(devicePixelRatio, 2)`), so an
|
|
1913
|
+
// active preset forces this resize even at an unchanged size.
|
|
1914
|
+
const emulatedDpr = deviceEmulatedPixelRatio();
|
|
1915
|
+
const actualW = gameContainer.clientWidth;
|
|
1916
|
+
const actualH = gameContainer.clientHeight;
|
|
1917
|
+
if (actualW > 0 && actualH > 0 && (actualW !== w || actualH !== h || emulatedDpr !== null)) {
|
|
1918
|
+
session.resize(actualW, actualH, emulatedDpr ?? undefined);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// Game is fully up: let the entry transition cross-fade once the camera
|
|
1922
|
+
// flight lands. The live-camera getter makes the flight converge on the
|
|
1923
|
+
// game's ACTUAL render camera (follow rigs / CameraDescriptor components may
|
|
1924
|
+
// have moved it during boot), so the hand-off is pixel-continuous.
|
|
1925
|
+
editorHost().viewport.transition.ready(() => {
|
|
1926
|
+
const game = _instance.session?.game;
|
|
1927
|
+
if (!game) return null;
|
|
1928
|
+
for (const world of game.roots) {
|
|
1929
|
+
if (world.mounted.kind === 'three') return world.mounted.camera;
|
|
1930
|
+
}
|
|
1931
|
+
return null;
|
|
1932
|
+
});
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
const msg = `Play mode failed to start: ${err}`;
|
|
1935
|
+
editorConsole.error(msg, 'play-mode');
|
|
1936
|
+
// Reclaim what THIS boot built but never handed over. `_instance.id` is
|
|
1937
|
+
// still `''` for every failure before the hand-off, so `exitPlayMode`'s own
|
|
1938
|
+
// teardown cannot see this mount at all: its `disposeInstanceRealm('')`
|
|
1939
|
+
// early-returns and its `clearGameSurface()` resets the DEFAULT realm.
|
|
1940
|
+
// Unconditional on the epoch — this realm and this session belong to THIS
|
|
1941
|
+
// boot and to nothing else, so a Stop that landed mid-boot (which skips the
|
|
1942
|
+
// `exitPlayMode` below) must not strand them either.
|
|
1943
|
+
if (bootMountId !== null && _instance.id !== bootMountId) {
|
|
1944
|
+
if (bootedSession) {
|
|
1945
|
+
try {
|
|
1946
|
+
bootedSession.stop();
|
|
1947
|
+
} catch (stopErr) {
|
|
1948
|
+
editorConsole.error(`Error stopping the failed play session: ${stopErr}`, 'play-mode');
|
|
1949
|
+
}
|
|
1950
|
+
disposeInstanceRealmAfterStop(bootedSession, bootMountId, 'Failed instance');
|
|
1951
|
+
} else {
|
|
1952
|
+
disposeInstanceRealm(bootMountId, 'Failed instance');
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
// Roll back only if THIS play is still the live generation — if it was
|
|
1956
|
+
// already exited during boot (epoch moved on), a second exitPlayMode here
|
|
1957
|
+
// could tear down a newer play that started in the meantime.
|
|
1958
|
+
if (epoch === _playEpoch) exitPlayMode();
|
|
1959
|
+
throw new Error(msg);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Mount an ADDITIONAL instance of this project beside the primary one — the
|
|
1965
|
+
* cardinality half of multiplayer authoring (see the `_additional` doc).
|
|
1966
|
+
*
|
|
1967
|
+
* Valid only while the primary is playing. The returned id is the instance's
|
|
1968
|
+
* mount id (what `?vgai-mount=` carries and what `game.instance(id)`
|
|
1969
|
+
* addresses). This does the INSTANCE subset of `enterPlayModeInner` and none
|
|
1970
|
+
* of its session/focus work: it resolves its own mount epoch, mounts the roots
|
|
1971
|
+
* into `container`, registers a performance source and its System adapters
|
|
1972
|
+
* under the mount id, and leaves editor focus, the store scene, authoring, the
|
|
1973
|
+
* console patch and the camera transition entirely to the primary.
|
|
1974
|
+
*/
|
|
1975
|
+
export async function mountAdditionalInstance(
|
|
1976
|
+
container: HTMLElement,
|
|
1977
|
+
name?: string,
|
|
1978
|
+
): Promise<string> {
|
|
1979
|
+
if (!_ctx) throw new Error('Cannot mount an additional instance: play mode is not bound.');
|
|
1980
|
+
if (!_instance.session) {
|
|
1981
|
+
throw new Error('Cannot mount an additional instance: no primary play session is running.');
|
|
1982
|
+
}
|
|
1983
|
+
// This function AWAITS (manifest fetch, entry resolution, the runtime mount);
|
|
1984
|
+
// play can STOP mid-flight (exitPlayMode bumps `_playEpoch` and nulls
|
|
1985
|
+
// `_instance.session`). The start guard above is stale by the time the awaits
|
|
1986
|
+
// finish, so capture the epoch and re-check it after mounting — otherwise
|
|
1987
|
+
// wiring this instance dereferences the torn-down primary (`_instance.session`
|
|
1988
|
+
// is null → "Cannot read properties of null (reading 'game')").
|
|
1989
|
+
const epoch = _playEpoch;
|
|
1990
|
+
const project = getCurrentProject();
|
|
1991
|
+
if (!project) throw new Error('Cannot mount an additional instance: no project is open.');
|
|
1992
|
+
const manifest = await fetchGameManifest();
|
|
1993
|
+
|
|
1994
|
+
// Its OWN mount epoch → its own id and its own per-url module graph. No
|
|
1995
|
+
// `editorPreview`: an additional instance is not the focused editor
|
|
1996
|
+
// viewport, so it resolves without the viewport-camera injection the primary
|
|
1997
|
+
// threads in. The split watch is mount-scoped and these mounts are
|
|
1998
|
+
// sequential, so opening one around this mount cannot overlap the primary's.
|
|
1999
|
+
beginProjectModuleSplitWatch();
|
|
2000
|
+
const { entries, mountId } = await resolveAllRootEntries(manifest, project.rootPath, {
|
|
2001
|
+
selectionOverride: _playSelectionOverride ?? undefined,
|
|
2002
|
+
});
|
|
2003
|
+
if (!container.style.contain) {
|
|
2004
|
+
container.style.cssText += GAME_SURFACE_CONTAINMENT_CSS;
|
|
2005
|
+
}
|
|
2006
|
+
markGameCssScope(container);
|
|
2007
|
+
setGameSurface(container, mountId);
|
|
2008
|
+
const { mountManifestRoots } = await import('@volter/game-runtime/runtime/mount-manifest');
|
|
2009
|
+
const session = await mountManifestRoots({
|
|
2010
|
+
manifest,
|
|
2011
|
+
container,
|
|
2012
|
+
entries,
|
|
2013
|
+
width: container.clientWidth,
|
|
2014
|
+
height: container.clientHeight,
|
|
2015
|
+
playtest: _activePlaytest,
|
|
2016
|
+
});
|
|
2017
|
+
for (const split of endProjectModuleSplitWatch(project.rootPath)) {
|
|
2018
|
+
editorConsole.error(formatProjectModuleSplitMessage(split), 'play-mode');
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Play stopped (or restarted) while we were mounting: the primary this
|
|
2022
|
+
// instance would attach beside is gone. Abandon the freshly-mounted session
|
|
2023
|
+
// cleanly rather than wiring it against a null primary. Returning the id (not
|
|
2024
|
+
// throwing) keeps the caller's teardown a no-op — the instance was never
|
|
2025
|
+
// pushed to `_additional`, so its per-viewport unmount finds nothing.
|
|
2026
|
+
if (epoch !== _playEpoch || !_instance.session) {
|
|
2027
|
+
try {
|
|
2028
|
+
session.stop();
|
|
2029
|
+
} catch {
|
|
2030
|
+
// Best-effort teardown of an instance nothing will ever address.
|
|
2031
|
+
}
|
|
2032
|
+
disposeInstanceRealmAfterStop(session, mountId, name?.trim() || 'Cancelled instance');
|
|
2033
|
+
return mountId;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
installAdapterRuntimeBindings(session.game);
|
|
2037
|
+
|
|
2038
|
+
const inst = createPlayInstance();
|
|
2039
|
+
inst.id = mountId;
|
|
2040
|
+
// Its label: the caller's name, else the "Instance N" default for its position.
|
|
2041
|
+
inst.name = name?.trim() || instanceNameAt(_additional.length + 1);
|
|
2042
|
+
inst.container = container;
|
|
2043
|
+
inst.session = session;
|
|
2044
|
+
inst.unregisterPerformanceSource = registerPerformanceSource({
|
|
2045
|
+
id: `workspace:game:${mountId}`,
|
|
2046
|
+
label: `Game runtime (instance ${mountId})`,
|
|
2047
|
+
kind: 'game',
|
|
2048
|
+
instanceId: mountId,
|
|
2049
|
+
profiler: session.game.profiler,
|
|
2050
|
+
});
|
|
2051
|
+
const systems = session.game.systemAdapters;
|
|
2052
|
+
setActiveSystems(systems, mountId);
|
|
2053
|
+
inst.unsubscribeSystemAdapters =
|
|
2054
|
+
session.game.subscribeSystemAdapters?.(() => {
|
|
2055
|
+
updateInstanceSystems(session.game.systemAdapters, mountId);
|
|
2056
|
+
}) ?? null;
|
|
2057
|
+
// FOCUS-AWARE input, exactly like the primary: this instance takes the shared
|
|
2058
|
+
// keyboard only while it holds focus. It mounts UNFOCUSED (the primary keeps
|
|
2059
|
+
// focus), so its gate is closed and its InputManager is disabled until a click
|
|
2060
|
+
// on its viewport routes focus here (`setFocusedInstance`). This is what stops
|
|
2061
|
+
// the pre-focus bug where every seat took the same keystroke, AND what lets
|
|
2062
|
+
// you drive a chosen seat manually rather than only via autoplay.
|
|
2063
|
+
setGameInputGate(() => instanceInputActive(mountId), mountId);
|
|
2064
|
+
_additional.push(inst);
|
|
2065
|
+
// Now that it is in `_additional`, sync its (and every) InputManager to the
|
|
2066
|
+
// current focus — disabled here, since the primary is focused.
|
|
2067
|
+
resyncInstanceInputs();
|
|
2068
|
+
|
|
2069
|
+
// `setActiveSystems` moved editor focus (`getActiveSystems`) onto the newer
|
|
2070
|
+
// mount. The user is still authoring the PRIMARY, so restore its focus
|
|
2071
|
+
// without disturbing either instance's addressed registration.
|
|
2072
|
+
const primarySystems = _instance.session.game.systemAdapters;
|
|
2073
|
+
setActiveSystems(primarySystems, _instance.id);
|
|
2074
|
+
|
|
2075
|
+
notifySessionListeners();
|
|
2076
|
+
return mountId;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/** Live additional-instance ids, in mount order — for the Game view and tests. */
|
|
2080
|
+
export function additionalInstanceIds(): string[] {
|
|
2081
|
+
return _additional.map((inst) => inst.id);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** Every live instance as `{ id, name }`, primary first — what `list-instances`
|
|
2085
|
+
* surfaces so a driver/HUD can show which mount is "Instance 2" without the
|
|
2086
|
+
* instance layer learning a game-specific role. Only genuinely-mounted
|
|
2087
|
+
* instances (a real id) are included. */
|
|
2088
|
+
export function instanceEntries(): { id: string; name: string }[] {
|
|
2089
|
+
const entries: { id: string; name: string }[] = [];
|
|
2090
|
+
if (_instance.id) entries.push({ id: _instance.id, name: _instance.name });
|
|
2091
|
+
for (const inst of _additional) if (inst.id) entries.push({ id: inst.id, name: inst.name });
|
|
2092
|
+
return entries;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function instanceNameForId(id: string): string | undefined {
|
|
2096
|
+
if (_instance.id === id) return _instance.name;
|
|
2097
|
+
return _additional.find((instance) => instance.id === id)?.name;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function disposeInstanceRealm(id: string, name: string): void {
|
|
2101
|
+
// An empty id means this run never resolved a composition, so it never
|
|
2102
|
+
// created a realm of its own. (A failed boot that DID get that far reclaims
|
|
2103
|
+
// its realm through `bootMountId` in `enterPlayModeInner`'s catch, which is
|
|
2104
|
+
// why this guard can stay.)
|
|
2105
|
+
if (!id) return;
|
|
2106
|
+
clearGameSurface(id);
|
|
2107
|
+
reclaimGameRealm(id, name);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/** A native React reconciler may finish component effect cleanup after the
|
|
2111
|
+
* synchronous stop() call returns. Audit the game realm only once every root
|
|
2112
|
+
* says that cleanup has landed; otherwise the editor races the owner, reclaims
|
|
2113
|
+
* listeners itself, and files a false leak warning. */
|
|
2114
|
+
function disposeInstanceRealmAfterStop(session: GameSession, id: string, name: string): void {
|
|
2115
|
+
void session.stopComplete.then(() => disposeInstanceRealm(id, name));
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** Tear down ONE additional instance by its mount id. Idempotent: a no-op if
|
|
2119
|
+
* the id is not (or no longer) a live additional instance, so the Game view's
|
|
2120
|
+
* per-viewport unmount and `exitPlayMode`'s bulk teardown can both fire for
|
|
2121
|
+
* the same instance without a double stop. */
|
|
2122
|
+
export function unmountAdditionalInstance(id: string): void {
|
|
2123
|
+
const idx = _additional.findIndex((inst) => inst.id === id);
|
|
2124
|
+
if (idx < 0) return;
|
|
2125
|
+
const [inst] = _additional.splice(idx, 1);
|
|
2126
|
+
if (!inst) return;
|
|
2127
|
+
inst.unsubscribeSystemAdapters?.();
|
|
2128
|
+
inst.unsubscribeSystemAdapters = null;
|
|
2129
|
+
const stoppingSession = inst.session;
|
|
2130
|
+
try {
|
|
2131
|
+
stoppingSession?.stop();
|
|
2132
|
+
} catch (err) {
|
|
2133
|
+
editorConsole.error(`Error stopping additional instance ${inst.id}: ${err}`, 'play-mode');
|
|
2134
|
+
}
|
|
2135
|
+
inst.unregisterPerformanceSource?.();
|
|
2136
|
+
setActiveSystems(null, inst.id);
|
|
2137
|
+
if (stoppingSession) disposeInstanceRealmAfterStop(stoppingSession, inst.id, inst.name);
|
|
2138
|
+
else disposeInstanceRealm(inst.id, inst.name);
|
|
2139
|
+
inst.container = null;
|
|
2140
|
+
inst.session = null;
|
|
2141
|
+
// If the removed instance held keyboard focus, focus falls back to the primary
|
|
2142
|
+
// (`focusedInstanceId` already resolves a stale id to it); re-sync so the
|
|
2143
|
+
// primary's InputManager re-enables, and notify the viewport highlight.
|
|
2144
|
+
if (_focusedInstanceId === id) {
|
|
2145
|
+
_focusedInstanceId = null;
|
|
2146
|
+
resyncInstanceInputs();
|
|
2147
|
+
notifyFocusedInstance();
|
|
2148
|
+
}
|
|
2149
|
+
notifySessionListeners();
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/** Tear down every additional instance. Called by `exitPlayMode`; each stops
|
|
2153
|
+
* its session and stops being addressable, symmetrically with the primary. */
|
|
2154
|
+
function unmountAdditionalInstances(): void {
|
|
2155
|
+
// Snapshot ids first — `unmountAdditionalInstance` mutates `_additional`.
|
|
2156
|
+
for (const id of additionalInstanceIds()) unmountAdditionalInstance(id);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* The desired split-screen layout, as the LABELS of the instances to show
|
|
2161
|
+
* (index 0 is the primary). This is the single source of truth for both how
|
|
2162
|
+
* many viewports the Game view renders and what each is named — a name is a
|
|
2163
|
+
* hint (see `PlayInstance.name`), never the mechanism. `[]` means no split
|
|
2164
|
+
* (one instance, no badges). Driven by `set-instance-count` /
|
|
2165
|
+
* `editor.instances(n | names[])`; reset on exit so a fresh play starts single.
|
|
2166
|
+
*/
|
|
2167
|
+
let _instanceNames: string[] = [];
|
|
2168
|
+
const extraInstanceListeners = new Set<() => void>();
|
|
2169
|
+
|
|
2170
|
+
/** The default label for the instance at `index` (0 = primary). When the
|
|
2171
|
+
* game's RUNTIME `NetworkingAdapter` exposes `getPlayerIdentity` (an OPTIONAL
|
|
2172
|
+
* capability a game implements only if it has a real, game-defined identity —
|
|
2173
|
+
* the editor never fabricates one), the PRIMARY instance reads that name off
|
|
2174
|
+
* the adapter instead of the generic default. The editor reads the adapter,
|
|
2175
|
+
* never infers: no adapter, no identity member, or a non-multiplayer game all
|
|
2176
|
+
* fall back to "Instance N". The edit-time adapter deliberately provides no
|
|
2177
|
+
* identity, so before Play every mount is "Instance N" unless a running game
|
|
2178
|
+
* supplies one. Only the primary (index 0) maps to the identity; the extras
|
|
2179
|
+
* are additional local seats and keep the numbered default. */
|
|
2180
|
+
function defaultInstanceName(index: number): string {
|
|
2181
|
+
if (index === 0) {
|
|
2182
|
+
const authored = getActiveNetworking()?.getPlayerIdentity?.()?.name?.trim();
|
|
2183
|
+
if (authored) return authored;
|
|
2184
|
+
}
|
|
2185
|
+
return `Instance ${index + 1}`;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** How many instances BESIDE the primary the Game view should show. */
|
|
2189
|
+
export function desiredExtraInstances(): number {
|
|
2190
|
+
return _instanceNames.length === 0 ? 0 : _instanceNames.length - 1;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** The label for the instance at `index` (0 = primary) — the explicit name if
|
|
2194
|
+
* one was given, else the "Instance N" default. */
|
|
2195
|
+
export function instanceNameAt(index: number): string {
|
|
2196
|
+
return _instanceNames[index] ?? defaultInstanceName(index);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
export function subscribeExtraInstances(listener: () => void): () => void {
|
|
2200
|
+
extraInstanceListeners.add(listener);
|
|
2201
|
+
return () => extraInstanceListeners.delete(listener);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function notifyExtraInstances(): void {
|
|
2205
|
+
for (const listener of extraInstanceListeners) listener();
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** Set how many EXTRA instances (beyond the primary) the Game view shows, with
|
|
2209
|
+
* default "Instance N" labels. Clamped at 0; the view reconciles to match. */
|
|
2210
|
+
export function setDesiredExtraInstances(count: number): void {
|
|
2211
|
+
const extra = Math.max(0, Math.floor(count));
|
|
2212
|
+
setDesiredInstanceNames(
|
|
2213
|
+
extra === 0 ? [] : Array.from({ length: extra + 1 }, (_v, i) => defaultInstanceName(i)),
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** Set the split layout by explicit labels (index 0 = primary). `names.length`
|
|
2218
|
+
* is the TOTAL instance count; `[]` or a single name collapses to no split. */
|
|
2219
|
+
export function setDesiredInstanceNames(names: string[]): void {
|
|
2220
|
+
const next =
|
|
2221
|
+
names.length <= 1 ? [] : names.map((n, i) => (n?.trim() ? n.trim() : defaultInstanceName(i)));
|
|
2222
|
+
if (next.length === _instanceNames.length && next.every((n, i) => n === _instanceNames[i]))
|
|
2223
|
+
return;
|
|
2224
|
+
_instanceNames = next;
|
|
2225
|
+
notifyExtraInstances();
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Exit play mode: stop game, remove canvas, re-enable editor.
|
|
2230
|
+
*/
|
|
2231
|
+
export function exitPlayMode(): void {
|
|
2232
|
+
// Cancel an in-flight boot even if the editor has not bound Play yet.
|
|
2233
|
+
_playEpoch++;
|
|
2234
|
+
cancelPendingWorkspacePlayUtilities();
|
|
2235
|
+
if (!_ctx) return;
|
|
2236
|
+
// The SAFETY NET for this run's recording, not its normal close.
|
|
2237
|
+
//
|
|
2238
|
+
// The relayed `stop` awaits `endPlayRecording('stop')` before calling this
|
|
2239
|
+
// (`command-listener.ts`), which is the ordered close. What reaches here
|
|
2240
|
+
// unclosed is every OTHER exit — the Play bar's Stop button, Escape, a boot
|
|
2241
|
+
// failure's rollback — and this function is synchronous, so the finalize can
|
|
2242
|
+
// only be fired, not awaited. It is idempotent and a no-op when the ordered
|
|
2243
|
+
// close already ran.
|
|
2244
|
+
void endPlayRecording('teardown');
|
|
2245
|
+
// No runtime, no Game subject and no `play` props — unconditionally, and
|
|
2246
|
+
// before the ingest early-return below, so no exit path can leave the play
|
|
2247
|
+
// surface's empty state (or a contribution) holding a game that has stopped.
|
|
2248
|
+
dropPlaySessionReaders();
|
|
2249
|
+
_playSelectionOverride = null;
|
|
2250
|
+
// A play run this editor handed to the ingest routes is theirs to end —
|
|
2251
|
+
// there is no first-party session, adopted scene or root authoring here to
|
|
2252
|
+
// tear down, and running the rest of this function over one would clear
|
|
2253
|
+
// state the ingest teardown owns. Returns false for every other run.
|
|
2254
|
+
if (exitDeferredIngestPlay(_ctx.store)) return;
|
|
2255
|
+
_activePlaytest = null;
|
|
2256
|
+
// Keyboard focus resets so the next play starts with the primary focused.
|
|
2257
|
+
_focusedInstanceId = null;
|
|
2258
|
+
notifyFocusedInstance();
|
|
2259
|
+
// PD-1: `_playStartedAtMs` is NOT cleared here — see its declaration. The
|
|
2260
|
+
// next enterPlayMode re-stamps it; clearing it on exit blinded every
|
|
2261
|
+
// `pageErrors` reader to the failure that caused the exit.
|
|
2262
|
+
const { store } = _ctx;
|
|
2263
|
+
// Drain while the session/debug registry still exists. Stopping disposes
|
|
2264
|
+
// it, and the last pickup/win event is often emitted inside the final 200ms
|
|
2265
|
+
// interval before Stop.
|
|
2266
|
+
drainDebugEventsToPlayLog();
|
|
2267
|
+
|
|
2268
|
+
// Additional instances stop WITH the session — the primary owns the play
|
|
2269
|
+
// lifecycle, so its exit ends every instance mounted beside it. Before the
|
|
2270
|
+
// primary teardown so their sessions/registrations are gone first. Reset the
|
|
2271
|
+
// desired split too, so the next play starts single-view.
|
|
2272
|
+
setDesiredExtraInstances(0);
|
|
2273
|
+
unmountAdditionalInstances();
|
|
2274
|
+
|
|
2275
|
+
// Reverse the entry transition first: restore the pre-play editor camera,
|
|
2276
|
+
// re-materialize the dock chrome, re-enable layout persistence. Idempotent
|
|
2277
|
+
// and safe on every exit path (Stop, Escape, boot failure, restart).
|
|
2278
|
+
editorHost().viewport.transition.end();
|
|
2279
|
+
|
|
2280
|
+
// Restore the viewport host's prior authored subject before stopping (the
|
|
2281
|
+
// runtime owns the presented native tree and may dispose it during stop).
|
|
2282
|
+
store.setEcsSyncTransform(null);
|
|
2283
|
+
// Unregister everything this mount registered under ITS id, so a stopped
|
|
2284
|
+
// instance stops being addressable instead of lingering as a bag that still
|
|
2285
|
+
// answers commands. Captured before anything clears it, because the gate is
|
|
2286
|
+
// released further down. `''` when play never got as far as resolving a
|
|
2287
|
+
// composition — which is exactly the id such a run would have used, if any.
|
|
2288
|
+
const mountId = _instance.id;
|
|
2289
|
+
const instanceName = _instance.name || 'Game instance';
|
|
2290
|
+
_instance.id = '';
|
|
2291
|
+
_instance.unsubscribeSystemAdapters?.();
|
|
2292
|
+
_instance.unsubscribeSystemAdapters = null;
|
|
2293
|
+
setActiveSystems(null, mountId);
|
|
2294
|
+
_instance.presentation?.dispose();
|
|
2295
|
+
_instance.presentation = null;
|
|
2296
|
+
exitPlayRootAuthoring(store);
|
|
2297
|
+
restorePriorAuthoring();
|
|
2298
|
+
// Readout-only — clear the regime the moment play is no longer active.
|
|
2299
|
+
store.setPlayEditRegime(null);
|
|
2300
|
+
|
|
2301
|
+
// Always null _instance.session even if stop() throws — otherwise enterPlayMode's
|
|
2302
|
+
// `if (_instance.session) return` guard would permanently block re-entering play mode.
|
|
2303
|
+
const stoppingSession = _instance.session;
|
|
2304
|
+
if (stoppingSession) {
|
|
2305
|
+
try {
|
|
2306
|
+
stoppingSession.stop();
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
editorConsole.error(`Error stopping play session: ${err}`, 'play-mode');
|
|
2309
|
+
} finally {
|
|
2310
|
+
_instance.unregisterPerformanceSource?.();
|
|
2311
|
+
_instance.unregisterPerformanceSource = null;
|
|
2312
|
+
_instance.session = null;
|
|
2313
|
+
_instance.determinismDeclared = false;
|
|
2314
|
+
notifySessionListeners();
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
if (stoppingSession) disposeInstanceRealmAfterStop(stoppingSession, mountId, instanceName);
|
|
2318
|
+
else disposeInstanceRealm(mountId, instanceName);
|
|
2319
|
+
|
|
2320
|
+
// RESOURCE OWNERSHIP: exactly the roots THIS play run recorded, dropped by
|
|
2321
|
+
// this run's one teardown. Per-id rather than a blanket clear, because a
|
|
2322
|
+
// deferred ingest root mounted beside this session records its own readiness
|
|
2323
|
+
// through its own lifecycle and its answer is still true.
|
|
2324
|
+
for (const rootId of _hostMountedReadyRootIds) clearRootReadiness(rootId);
|
|
2325
|
+
_hostMountedReadyRootIds = [];
|
|
2326
|
+
|
|
2327
|
+
// Cleanup subscriptions
|
|
2328
|
+
_unsubStore?.();
|
|
2329
|
+
_unsubStore = null;
|
|
2330
|
+
|
|
2331
|
+
// T6.3: no game running — game input (raw window/document listeners AND the
|
|
2332
|
+
// InputManager sync above) should never be suppressed again until the next
|
|
2333
|
+
// play session re-gates it. The mount's own gate is DROPPED rather than set
|
|
2334
|
+
// to always-true: its id is never reused, so overwriting would retain one
|
|
2335
|
+
// dead closure per play run.
|
|
2336
|
+
setGameInputGate(() => true);
|
|
2337
|
+
clearGameSurface();
|
|
2338
|
+
|
|
2339
|
+
_instance.resizeObserver?.disconnect();
|
|
2340
|
+
_instance.resizeObserver = null;
|
|
2341
|
+
|
|
2342
|
+
if (_escapeListener) {
|
|
2343
|
+
window.removeEventListener('keydown', _escapeListener);
|
|
2344
|
+
_escapeListener = null;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// Log before clearing sink so this message gets persisted
|
|
2348
|
+
editorConsole.log('Play mode stopped', 'play-mode');
|
|
2349
|
+
|
|
2350
|
+
// PD-3 — a stopped session has no live roots to disagree, so its split
|
|
2351
|
+
// reports must not outlive it (the PD-1 lesson: a diagnostic that cannot
|
|
2352
|
+
// go back to healthy is worse than none).
|
|
2353
|
+
clearProjectModuleSplitReports();
|
|
2354
|
+
|
|
2355
|
+
// Flush remaining log entries and end session
|
|
2356
|
+
if (_flushInterval) {
|
|
2357
|
+
clearInterval(_flushInterval);
|
|
2358
|
+
_flushInterval = null;
|
|
2359
|
+
}
|
|
2360
|
+
// The interval is only the steady-state drain. Stop is a boundary of its
|
|
2361
|
+
// own: debug events emitted after the last 200ms turn are still evidence and
|
|
2362
|
+
// must join the same awaited queue before the server closes the session.
|
|
2363
|
+
drainDebugEventsToPlayLog();
|
|
2364
|
+
editorConsole.setSink(null);
|
|
2365
|
+
if (_pendingEntries.length > 0) {
|
|
2366
|
+
const batch = _pendingEntries.splice(0);
|
|
2367
|
+
_logFlushChain = _logFlushChain.then(() => flushLogEntries(batch));
|
|
2368
|
+
}
|
|
2369
|
+
// The server has one active log file. End it only after every queued batch
|
|
2370
|
+
// has reached the append endpoint, otherwise Stop can race the final fetch
|
|
2371
|
+
// and silently discard the most useful end-of-run evidence.
|
|
2372
|
+
_logFlushChain = _logFlushChain.then(() => endLogSession());
|
|
2373
|
+
|
|
2374
|
+
unpatchConsole();
|
|
2375
|
+
|
|
2376
|
+
// setPlayState('stopped') auto-switches back to the Edit tab, and its
|
|
2377
|
+
// live → stopped edge is what normally closes the Game document. A play
|
|
2378
|
+
// that FAILED before ever flipping the store to 'playing' never produces
|
|
2379
|
+
// that edge, so release directly too — `releaseGameDocument` is the one
|
|
2380
|
+
// idempotent teardown path either way (`game-document.ts`).
|
|
2381
|
+
store.setPlayState('stopped');
|
|
2382
|
+
editorHost().workspace.liveDocument.release();
|
|
2383
|
+
// LAST — the run's error window closes only once teardown is done, so every
|
|
2384
|
+
// error this teardown itself logged still belongs to the run that caused it
|
|
2385
|
+
// (PD-1). Errors after this instant are the SESSION's, and the
|
|
2386
|
+
// `sessionErrors` facet is what reports them.
|
|
2387
|
+
//
|
|
2388
|
+
// Guarded on the window actually being OPEN: this function is idempotent and
|
|
2389
|
+
// callers invoke it unconditionally (the `stop` command relays here even when
|
|
2390
|
+
// nothing is playing, and the lease-void teardown can fire while stopped). An
|
|
2391
|
+
// unconditional stamp would move a CLOSED window's end forward on every
|
|
2392
|
+
// redundant stop, silently reclassifying the session errors logged since the
|
|
2393
|
+
// real end back into the play-fenced facets — which the CLI only prints while
|
|
2394
|
+
// play is live. That is the exact hiding this window exists to end.
|
|
2395
|
+
if (_playStartedAtMs !== null && _playEndedAtMs === null) _playEndedAtMs = Date.now();
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* Pause play mode: freeze game, allow editor inspection.
|
|
2400
|
+
*/
|
|
2401
|
+
export function pausePlayMode(): void {
|
|
2402
|
+
if (!_instance.session || !_ctx) return;
|
|
2403
|
+
// Freeze EVERY seat, not just the primary — a paused split with the extras
|
|
2404
|
+
// still ticking is not paused.
|
|
2405
|
+
for (const inst of allLiveInstances()) inst.session?.pause();
|
|
2406
|
+
_ctx.store.setPlayState('paused');
|
|
2407
|
+
|
|
2408
|
+
editorConsole.log('Play mode paused', 'play-mode');
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Resume play mode from pause.
|
|
2413
|
+
*/
|
|
2414
|
+
export function resumePlayMode(): void {
|
|
2415
|
+
if (!_instance.session || !_ctx) return;
|
|
2416
|
+
|
|
2417
|
+
for (const inst of allLiveInstances()) inst.session?.resume();
|
|
2418
|
+
_ctx.store.setPlayState('playing');
|
|
2419
|
+
|
|
2420
|
+
editorConsole.log('Play mode resumed', 'play-mode');
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* Step one fixed-timestep frame while paused.
|
|
2425
|
+
*/
|
|
2426
|
+
export function stepPlayMode(): void {
|
|
2427
|
+
if (!_instance.session) return;
|
|
2428
|
+
for (const inst of allLiveInstances()) inst.session?.step();
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// --- Console patching ---
|
|
2432
|
+
|
|
2433
|
+
function patchConsole(): void {
|
|
2434
|
+
// LAYERING (the full note lives on `installEditorConsoleCapture` in
|
|
2435
|
+
// `editor-console.ts` — read the two together). The session-lifetime capture
|
|
2436
|
+
// installed at editor boot is what `console.error`/`console.warn` currently
|
|
2437
|
+
// ARE, so the wrappers below sit OUTSIDE it and call through to it. While
|
|
2438
|
+
// this run is live THIS patch owns the funnel and tags entries 'game' — the
|
|
2439
|
+
// more specific answer — so the boot capture must not also push the same
|
|
2440
|
+
// message as 'editor'. Suspend it for exactly the lifetime of this patch.
|
|
2441
|
+
suspendEditorConsoleCapture();
|
|
2442
|
+
_originalConsoleLog = console.log;
|
|
2443
|
+
_originalConsoleInfo = console.info;
|
|
2444
|
+
_originalConsoleWarn = console.warn;
|
|
2445
|
+
_originalConsoleError = console.error;
|
|
2446
|
+
|
|
2447
|
+
const capture = (level: ConsoleEntry['level'], args: readonly unknown[]) => {
|
|
2448
|
+
if (_engineLogActive) return;
|
|
2449
|
+
const instanceId = currentGameRealmMountId();
|
|
2450
|
+
editorConsole.logStructured(
|
|
2451
|
+
level,
|
|
2452
|
+
formatConsoleArgs(args),
|
|
2453
|
+
'game',
|
|
2454
|
+
undefined,
|
|
2455
|
+
instanceId
|
|
2456
|
+
? { instanceId, instanceName: instanceNameForId(instanceId) ?? `Instance ${instanceId}` }
|
|
2457
|
+
: undefined,
|
|
2458
|
+
);
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
console.log = (...args: unknown[]) => {
|
|
2462
|
+
_originalConsoleLog?.apply(console, args);
|
|
2463
|
+
capture('info', args);
|
|
2464
|
+
};
|
|
2465
|
+
console.info = (...args: unknown[]) => {
|
|
2466
|
+
_originalConsoleInfo?.apply(console, args);
|
|
2467
|
+
capture('info', args);
|
|
2468
|
+
};
|
|
2469
|
+
console.warn = (...args: unknown[]) => {
|
|
2470
|
+
_originalConsoleWarn?.apply(console, args);
|
|
2471
|
+
capture('warn', args);
|
|
2472
|
+
};
|
|
2473
|
+
console.error = (...args: unknown[]) => {
|
|
2474
|
+
_originalConsoleError?.apply(console, args);
|
|
2475
|
+
capture('error', args);
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function unpatchConsole(): void {
|
|
2480
|
+
// Hand the funnel back to the boot-installed session-lifetime capture
|
|
2481
|
+
// (`editor-console.ts`) — restoring the saved originals below re-exposes its
|
|
2482
|
+
// wrappers, and this is what lets them push to the store again.
|
|
2483
|
+
resumeEditorConsoleCapture();
|
|
2484
|
+
if (_originalConsoleLog) console.log = _originalConsoleLog;
|
|
2485
|
+
if (_originalConsoleInfo) console.info = _originalConsoleInfo;
|
|
2486
|
+
if (_originalConsoleWarn) console.warn = _originalConsoleWarn;
|
|
2487
|
+
if (_originalConsoleError) console.error = _originalConsoleError;
|
|
2488
|
+
_originalConsoleLog = null;
|
|
2489
|
+
_originalConsoleInfo = null;
|
|
2490
|
+
_originalConsoleWarn = null;
|
|
2491
|
+
_originalConsoleError = null;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// THE PLAY LANE, as the host sees it: the host stops asking this module by
|
|
2495
|
+
// name and asks its live-session registry instead. Registered at the module's
|
|
2496
|
+
// LOAD, which the contribution loader performs for every module this package
|
|
2497
|
+
// declares — so the lane exists from the moment a project that depends on
|
|
2498
|
+
// `@volter/editor-game` opens, long before anything asks to play.
|
|
2499
|
+
editorHost().live.register({
|
|
2500
|
+
id: 'play',
|
|
2501
|
+
priority: 0,
|
|
2502
|
+
mounted: isPlayModeActive,
|
|
2503
|
+
playing: isPlayModeActive,
|
|
2504
|
+
stop: exitPlayMode,
|
|
2505
|
+
instanceContainer: (id) => getInstanceContainer(id),
|
|
2506
|
+
startedAt: getPlayStartedAt,
|
|
2507
|
+
endedAt: getPlayEndedAt,
|
|
2508
|
+
restartRequired: getRestartRequiredReason,
|
|
2509
|
+
restart: () => void enterPlayMode(),
|
|
2510
|
+
// The live remount `open-scene` asks for: a scene entry opened while Play
|
|
2511
|
+
// runs re-serves the realm rather than restarting the session.
|
|
2512
|
+
remount: async (args) => {
|
|
2513
|
+
try {
|
|
2514
|
+
await enterPlayMode(undefined, undefined, undefined, args);
|
|
2515
|
+
if (!isPlayModeActive()) {
|
|
2516
|
+
return {
|
|
2517
|
+
ok: false,
|
|
2518
|
+
error: 'Play did not start: the remount was superseded before it finished booting.',
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
return { ok: true };
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
2524
|
+
}
|
|
2525
|
+
},
|
|
2526
|
+
});
|
|
2527
|
+
|
|
2528
|
+
// What only Play knows of the state report (`host.session.reportFacet`):
|
|
2529
|
+
// `vgai status --json` spreads these beside the host's own fields.
|
|
2530
|
+
editorHost().session.reportFacet(() => ({
|
|
2531
|
+
// The live loop's time-scale, so `play.status` reports the real applied
|
|
2532
|
+
// value after a `set-time-scale` instead of an honest-gap null.
|
|
2533
|
+
timeScale: getPlayRuntimeAccess()?.loop.timeScale ?? null,
|
|
2534
|
+
// Issue #175 — the REAL loop liveness (`GameLoop.liveness`), NOT the
|
|
2535
|
+
// store's playState: that stays 'playing' even while the host loop reports
|
|
2536
|
+
// `loop-starved` (no recent rAF progress — see game-loop.ts). Reading only
|
|
2537
|
+
// playState is exactly the gap that let a frozen game report as healthy
|
|
2538
|
+
// (measured: playState "playing", sim speed 0.00x over 32.9s wall). `null`
|
|
2539
|
+
// outside play mode — there is no loop to report on.
|
|
2540
|
+
loopLiveness: getPlayRuntimeAccess()?.loop.liveness ?? null,
|
|
2541
|
+
// The pending-restart reason the PlayBar's Restart button is currently
|
|
2542
|
+
// surfacing (source changed while the game is running — e.g. an R3F entry
|
|
2543
|
+
// write-back, a registry.ts edit), or null when the running session is
|
|
2544
|
+
// fresh. Agents need the same "your running game is stale, restart play"
|
|
2545
|
+
// signal humans get.
|
|
2546
|
+
restartRequired: getRestartRequiredReason(),
|
|
2547
|
+
// D15/T-D15.6: the live session's ctx.random root seed (real — reflects the
|
|
2548
|
+
// boot seed or the last successful play.seed.set), and whether the running
|
|
2549
|
+
// project declares determinism.seededRandom at all. Both null/false when no
|
|
2550
|
+
// first-party Game is running.
|
|
2551
|
+
seed: getPlayRuntimeAccess()?.random?.seed ?? null,
|
|
2552
|
+
deterministic: getPlayRuntimeAccess()?.determinismDeclared ?? false,
|
|
2553
|
+
}));
|