@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,1174 @@
|
|
|
1
|
+
import { onAssetReload } from '@volter/editor-core/project-asset-refresh';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* R3F design session (W4) — mounts an entry-based R3F three world at DESIGN
|
|
5
|
+
* TIME so the editor's native viewport, gizmo, hierarchy, and inspector drive
|
|
6
|
+
* the LIVE fiber scene in EDIT mode, with writes going back to the `.tsx`
|
|
7
|
+
* source through `R3fSourceAuthoringAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* The project mounts against a design host borrowing the viewport renderer
|
|
10
|
+
* for offscreen GPU work. Fiber's canvas and renderer lifecycle remain owned
|
|
11
|
+
* by the host; its simulation uses frameloop 'never'. No second context is
|
|
12
|
+
* created and no automatic game tick runs in Edit mode.
|
|
13
|
+
* The EDITOR's own renderer draws the scene: the fiber `THREE.Scene` is
|
|
14
|
+
* adopted into the store (`enterPlayScene`, the same swap play/ingest use —
|
|
15
|
+
* viewport `setScene`, objectMap from the adapter's stamped `entityId`s,
|
|
16
|
+
* composer rebuild), while `playState` stays 'stopped', so this is an
|
|
17
|
+
* EDIT-mode surface: hierarchy/pick/gizmo all work through the existing
|
|
18
|
+
* native-three machinery.
|
|
19
|
+
*
|
|
20
|
+
* Edit shows the AUTHORED pose and never advances content time. Physics preview
|
|
21
|
+
* is explicit simulation, just as animation preview is explicit transport;
|
|
22
|
+
* opening a scene must not hide it behind a build-quiescence wait plus 90
|
|
23
|
+
* invisible simulation steps. That is both the architecture's Edit ≠ Play
|
|
24
|
+
* rule and the ordinary Unity/Godot scene-editor contract.
|
|
25
|
+
*
|
|
26
|
+
* Component-only R3F modules use native Fast Refresh on this persistent root.
|
|
27
|
+
* Mixed exports still use the controlled entry-update/remount path. Completed
|
|
28
|
+
* HMR source hashes acknowledge matching collaboration revisions; a revision
|
|
29
|
+
* whose update was lost retains a bounded cold-remount fallback. The native
|
|
30
|
+
* authoring adapter observes reconciled child additions/removals.
|
|
31
|
+
*
|
|
32
|
+
* Play handoff mirrors `design-time-layers.ts`: entering play suspends the
|
|
33
|
+
* session (scene restored, fiber root disposed, Boundary disclosure node
|
|
34
|
+
* back in the composite); Stop queues one edit-mode rebuild which recreates
|
|
35
|
+
* the session fresh.
|
|
36
|
+
*
|
|
37
|
+
* DEBUG PLANE OWNERSHIP (ARCHITECTURE-CORE §Editor chrome — "the edit-time
|
|
38
|
+
* design session PUBLISHES its debug plane"), stated once, here:
|
|
39
|
+
*
|
|
40
|
+
* - OWNER: this session. While a design world is mounted and `playState` is
|
|
41
|
+
* 'stopped', it registers the design `Game`'s own `systemAdapters` — its
|
|
42
|
+
* game-scoped debug registry, carrying whatever the mounted world actually
|
|
43
|
+
* registered — through the SAME `setActiveSystems` door play/ingest/module
|
|
44
|
+
* modes use, under the UNNAMED (solo) seat. That is what lets the relay
|
|
45
|
+
* answer `list-gameplay-state`/`inspect-gameplay-state`/
|
|
46
|
+
* `list-debug-commands`/`invoke-debug-command` and `vgai eval`'s
|
|
47
|
+
* `game.state()`/`game.commands()` from the EDIT world. It publishes
|
|
48
|
+
* declarations, never a run: the loop is still never advanced.
|
|
49
|
+
* - SHARERS: none, ever, at the same instant. Play mode registers its mount
|
|
50
|
+
* under its OWN mount id, so a seat held by both would make
|
|
51
|
+
* `systemsForInstance(undefined)` ambiguous and break every unaddressed
|
|
52
|
+
* `vgai eval` call. The store subscription below therefore withdraws this
|
|
53
|
+
* seat the moment `playState` leaves 'stopped' — synchronously, at
|
|
54
|
+
* `store.setPlayState('playing')`, long before play's own
|
|
55
|
+
* `setActiveSystems` lands — and republishes when play ends without a
|
|
56
|
+
* rebuild. (When play DID suspend this session, Stop queues the rebuild and
|
|
57
|
+
* the FRESH session publishes; this one only withdraws.)
|
|
58
|
+
* - TEARDOWN: `withdrawPlane()` is the one path that ends it, and it is
|
|
59
|
+
* idempotent. Its callers are the play-entry subscription, `disposeMounted`
|
|
60
|
+
* (so an HMR remount can never leave the disposed game's stripped registry
|
|
61
|
+
* published), and the returned disposer.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { setActiveSystems, updateInstanceSystems } from '@volter/editor-core/authoring/active-systems';
|
|
65
|
+
import {
|
|
66
|
+
BoundaryAuthoringAdapter,
|
|
67
|
+
type BoundaryRootInfo,
|
|
68
|
+
} from '@volter/editor-core/authoring/boundary-authoring-adapter';
|
|
69
|
+
import type { CompositeAuthoringAdapter } from '@volter/editor-core/authoring/composite-authoring-adapter';
|
|
70
|
+
import {
|
|
71
|
+
collaborationSnapshot,
|
|
72
|
+
subscribeCollaborationRevision,
|
|
73
|
+
} from '@volter/editor-core/collaboration-client';
|
|
74
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
75
|
+
import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
|
|
76
|
+
import { adjudicateThreeEntry } from '../../host/entry-adjudication';
|
|
77
|
+
import { onPlayTransitionSettled } from '@volter/editor-core/live-transition';
|
|
78
|
+
import { fetchRawGameManifest } from '@volter/editor-core/manifest-project';
|
|
79
|
+
import { getCurrentProject } from '@volter/editor-core/project-manager';
|
|
80
|
+
import { activeRealmServices } from '../../host/realm-services';
|
|
81
|
+
import { pickGameCamera } from '@volter/editor-core/scene-framing';
|
|
82
|
+
import { tierSourceWriteBackend } from '@volter/editor-core/ui-source/tier-source-write-backend';
|
|
83
|
+
import type {
|
|
84
|
+
MountedThreeRoot,
|
|
85
|
+
RootAdapter,
|
|
86
|
+
SystemAdapters,
|
|
87
|
+
} from '@volter/editor-project/adapter';
|
|
88
|
+
import type { GameThreeHostContext } from '@volter/game-runtime/runtime/host-context';
|
|
89
|
+
import { nodeKeyedPhysics } from '@volter/editor-project/adapter';
|
|
90
|
+
import { declaredRoots, rootById } from '@volter/editor-project/adapter/manifest-interpreter';
|
|
91
|
+
import {
|
|
92
|
+
installNativeDebugBindings,
|
|
93
|
+
installNativeSystemsBindings,
|
|
94
|
+
type NativeDebugBinding,
|
|
95
|
+
type NativeSystemsBinding,
|
|
96
|
+
nativeDebugBindingFromEntryModule,
|
|
97
|
+
nativeSystemsBindingFromEntryModule,
|
|
98
|
+
} from '@volter/game-runtime/adapter/native-debug-module';
|
|
99
|
+
import { createAssetCache } from '@volter/threejs-runtime/assets';
|
|
100
|
+
import { createGameLoop } from '@volter/game-runtime/core/game-loop';
|
|
101
|
+
import { registerThreeRoot } from '@volter/game-runtime/runtime/create-runtime';
|
|
102
|
+
import { createGame, type GameInternal } from '@volter/game-runtime/runtime/game';
|
|
103
|
+
import {
|
|
104
|
+
beginProjectMountEpoch,
|
|
105
|
+
viteUpdateImportPath,
|
|
106
|
+
} from '@volter/editor-sdk/session/project-module-url';
|
|
107
|
+
import * as THREE from 'three';
|
|
108
|
+
import { createDesignTimeRenderer } from './design-time-renderer';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which three world is FOCUSED — the project's three root.
|
|
112
|
+
*
|
|
113
|
+
* The `find` is EXACT, not a first-wins pick: a project declares at most one
|
|
114
|
+
* world root per medium, and `GameManifestSchema`'s `roots` `superRefine`
|
|
115
|
+
* rejects a manifest with a second `three` root outright. So there is nothing
|
|
116
|
+
* for this to choose between — it returns the one three root, or `null` when
|
|
117
|
+
* the project has none.
|
|
118
|
+
*
|
|
119
|
+
* Every three root is entry-based, so focus is a property of the manifest
|
|
120
|
+
* alone — nothing about what is currently loaded can move it. It lives HERE,
|
|
121
|
+
* in this integration, because it names a surface: the kit's edit-mode
|
|
122
|
+
* installer asks `active-adapter.ts`'s factory seam by surface instead.
|
|
123
|
+
*/
|
|
124
|
+
function resolveThreeRootId(
|
|
125
|
+
roots: readonly { readonly id: string; readonly surface: string }[],
|
|
126
|
+
): string | null {
|
|
127
|
+
return roots.find((w) => w.surface === 'three')?.id ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
import {
|
|
131
|
+
type EditModeRootSpec,
|
|
132
|
+
parseEditModeManifest,
|
|
133
|
+
queueEditModeRebuild,
|
|
134
|
+
} from '@volter/editor-core/authoring/edit-mode-authoring';
|
|
135
|
+
import { liveGestureActive, whenLiveGestureIdle } from '@volter/editor-core/authoring/live-gesture-lock';
|
|
136
|
+
import {
|
|
137
|
+
addMountFailureReport,
|
|
138
|
+
clearMountFailureReport,
|
|
139
|
+
formatMountFailureMessage,
|
|
140
|
+
} from '@volter/editor-core/authoring/mount-failure-report';
|
|
141
|
+
import { SelectionRemountHandoff } from '../../host/authoring/selection-remount-handoff';
|
|
142
|
+
import {
|
|
143
|
+
type RefreshSource,
|
|
144
|
+
SourceRefreshRevisions,
|
|
145
|
+
} from '../../host/authoring/source-refresh-revisions';
|
|
146
|
+
import type { R3fSourceAuthoringAdapter } from './r3f-source-authoring-adapter';
|
|
147
|
+
import { isThreeScene } from './three-scene-identity';
|
|
148
|
+
|
|
149
|
+
// The world root's stage is recreated when the workspace changes.
|
|
150
|
+
// The outgoing panel restores the editor scene and the incoming panel loads
|
|
151
|
+
// it again before remounting the same R3F world; both operations clear the
|
|
152
|
+
// shared EditorShellStore selection. Keep a same-store handoff outside that reset
|
|
153
|
+
// path. WeakMap prevents a closed project/store from being retained.
|
|
154
|
+
const selectionRemountHandoff = new SelectionRemountHandoff<EditorShellStore>();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A three entry that is neither a `RootAdapter` nor a default-exported
|
|
158
|
+
* component used to return `null` from the design session's import, which
|
|
159
|
+
* then `return () => {}` — a blank Scene, `mountFailures: []`, no console
|
|
160
|
+
* error. The coverage warning ("seven editor providers missing") is not
|
|
161
|
+
* loudness. Name the root and the file so the empty viewport says why.
|
|
162
|
+
*/
|
|
163
|
+
export function unresolvedDesignEntryError(
|
|
164
|
+
worldId: string,
|
|
165
|
+
entry: string,
|
|
166
|
+
exported: readonly string[],
|
|
167
|
+
): Error {
|
|
168
|
+
return new Error(
|
|
169
|
+
`three root "${worldId}" failed to mount: ${entry} did not export a RootAdapter or a ` +
|
|
170
|
+
`default React component (exports: ${exported.join(', ') || '(nothing)'}).`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The one loudness write for a design-mount failure. `failToBoundary` (and
|
|
176
|
+
* tests) go through this so a blank Scene cannot lose the named report.
|
|
177
|
+
*/
|
|
178
|
+
export function reportDesignMountFailure(worldId: string, err: unknown): string {
|
|
179
|
+
const message = formatMountFailureMessage(err);
|
|
180
|
+
editorConsole.error(`[r3f-design] world "${worldId}" failed to mount: ${message}`, 'authoring');
|
|
181
|
+
addMountFailureReport({
|
|
182
|
+
worldId,
|
|
183
|
+
kind: 'three',
|
|
184
|
+
identity: 'default-three',
|
|
185
|
+
message,
|
|
186
|
+
});
|
|
187
|
+
return message;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Load the component Edit mounts and the manifest root's static module surface
|
|
192
|
+
* in one realm epoch. They are frequently the same file; translated players
|
|
193
|
+
* deliberately split them so Edit can open an authored scene without booting
|
|
194
|
+
* the player shell. In that split case, declarations still belong to the root
|
|
195
|
+
* entry and must not be looked up on the scene module.
|
|
196
|
+
*/
|
|
197
|
+
export async function loadR3FDesignEntryModules(
|
|
198
|
+
realm: Pick<Awaited<ReturnType<typeof activeRealmServices>>, 'loadEntryModule'>,
|
|
199
|
+
worldId: string,
|
|
200
|
+
designEntry: string,
|
|
201
|
+
rootEntry: string,
|
|
202
|
+
): Promise<{
|
|
203
|
+
designModule: Record<string, unknown>;
|
|
204
|
+
rootModule: Record<string, unknown>;
|
|
205
|
+
}> {
|
|
206
|
+
const designModule = await realm.loadEntryModule(designEntry, worldId, 'three');
|
|
207
|
+
const rootModule =
|
|
208
|
+
rootEntry === designEntry
|
|
209
|
+
? designModule
|
|
210
|
+
: await realm.loadEntryModule(rootEntry, worldId, 'three');
|
|
211
|
+
return { designModule, rootModule };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Complete and install one design root's declared observation/system plane. */
|
|
215
|
+
export function registerR3FDesignRoot(
|
|
216
|
+
game: GameInternal,
|
|
217
|
+
adapter: RootAdapter,
|
|
218
|
+
mounted: MountedThreeRoot,
|
|
219
|
+
worldId: string,
|
|
220
|
+
entryDebug: NativeDebugBinding | null,
|
|
221
|
+
entrySystems: NativeSystemsBinding | null,
|
|
222
|
+
): void {
|
|
223
|
+
registerThreeRoot(game, adapter, mounted, { id: worldId });
|
|
224
|
+
installNativeDebugBindings(game, entryDebug ? [entryDebug] : []);
|
|
225
|
+
installNativeSystemsBindings(game, entrySystems ? [entrySystems] : []);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The design host borrows the viewport renderer (see the module doc comment).
|
|
230
|
+
*
|
|
231
|
+
* It carries a real `Game` whose loop is NEVER started. Without one, every
|
|
232
|
+
* game-owned service would be missing, and behavior written the idiomatic
|
|
233
|
+
* way — a hook reading the game handle (`useOptionalGame()?.input`) at the
|
|
234
|
+
* top, as the starter's player controller does — would find nothing at
|
|
235
|
+
* design time. The pre-D26 shape hid this by accident: a
|
|
236
|
+
* a component reads `ctx.input` in `useFrame`, and design time never ticks.
|
|
237
|
+
*
|
|
238
|
+
* A Game here is not a shim — it is the real object, just inert: nothing
|
|
239
|
+
* advances the loop, so no frame, physics step or `useFrame` callback ever
|
|
240
|
+
* runs, and design time stays the still scene this session's contract
|
|
241
|
+
* promises. It also gets its OWN debug registry (registries are game-scoped),
|
|
242
|
+
* so design-time providers can't collide with the play game's.
|
|
243
|
+
*
|
|
244
|
+
* Input is explicitly DISABLED: `new InputManager()` binds window-level
|
|
245
|
+
* keyboard/mouse listeners in its constructor, and design time is not play
|
|
246
|
+
* (T6.3's invariant). `dispose()` unbinds them — the caller must call it.
|
|
247
|
+
*
|
|
248
|
+
* Its renderer is `createDesignTimeRenderer`, which isolates Fiber configuration
|
|
249
|
+
* and permits real offscreen work without transferring canvas ownership.
|
|
250
|
+
*/
|
|
251
|
+
function createDesignHost(renderer: THREE.WebGLRenderer): {
|
|
252
|
+
host: GameThreeHostContext;
|
|
253
|
+
game: GameInternal;
|
|
254
|
+
dispose: () => void;
|
|
255
|
+
} {
|
|
256
|
+
const canvas = document.createElement('canvas');
|
|
257
|
+
const borrowedRenderer = createDesignTimeRenderer(canvas, renderer);
|
|
258
|
+
const assets = createAssetCache();
|
|
259
|
+
const game = createGame({ loop: createGameLoop({ update: () => {} }), assets });
|
|
260
|
+
game.input.setEnabled(false);
|
|
261
|
+
return {
|
|
262
|
+
game,
|
|
263
|
+
host: {
|
|
264
|
+
three: THREE,
|
|
265
|
+
surface: { canvas, width: 1280, height: 720 },
|
|
266
|
+
renderer: borrowedRenderer,
|
|
267
|
+
assets,
|
|
268
|
+
headless: false,
|
|
269
|
+
game,
|
|
270
|
+
},
|
|
271
|
+
dispose: () => game.dispose(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function boundaryInfo(world: EditModeRootSpec): BoundaryRootInfo {
|
|
276
|
+
return {
|
|
277
|
+
id: world.id,
|
|
278
|
+
kind: world.surface,
|
|
279
|
+
adapter: world.adapter,
|
|
280
|
+
entryOrScenePath: world.entry ?? world.scene,
|
|
281
|
+
zOrder: world.zOrder ?? 0,
|
|
282
|
+
pausable: world.pausable ?? true,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rebuildWhenPlayStops(store: EditorShellStore): () => void {
|
|
287
|
+
let disposed = false;
|
|
288
|
+
let requested = false;
|
|
289
|
+
const request = () => {
|
|
290
|
+
if (disposed || requested || store.playState !== 'stopped') return;
|
|
291
|
+
requested = true;
|
|
292
|
+
queueEditModeRebuild();
|
|
293
|
+
};
|
|
294
|
+
const unsubscribe = store.subscribe(request);
|
|
295
|
+
request();
|
|
296
|
+
return () => {
|
|
297
|
+
disposed = true;
|
|
298
|
+
unsubscribe();
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Mount the R3F design session for the focused ENTRY-BASED three world (if
|
|
304
|
+
* this project has one and this session can serve it). Returns a disposer.
|
|
305
|
+
* A project whose Three world is a `setup`-export entry no-ops.
|
|
306
|
+
*
|
|
307
|
+
* BROWSER (hosted, no dev server) sessions run this too. Three dev-server
|
|
308
|
+
* dependencies had to be replaced first:
|
|
309
|
+
* - the entry import was a `/@fs/<abs path>` URL only Vite can serve;
|
|
310
|
+
* - source write-back posted to `/__ui-source/*` →
|
|
311
|
+
* the session's recorder reads/writes the same files through
|
|
312
|
+
* storage, running the SAME `planSourceEdit` the server does;
|
|
313
|
+
* - the remount trigger was Vite's `vgai:r3f-entry-update` HMR event →
|
|
314
|
+
* `StorageBackend.watch`, which in a server-less editor reports the
|
|
315
|
+
* editor's own writes (exactly the signal absorb-by-remount needs).
|
|
316
|
+
* A hosted EXAMPLE (`?project=<id>`) is still excluded: it is opened read-only
|
|
317
|
+
* and has no writable storage root, so there is nothing honest to write back
|
|
318
|
+
* to.
|
|
319
|
+
*/
|
|
320
|
+
export async function mountR3FDesignSession(
|
|
321
|
+
store: EditorShellStore,
|
|
322
|
+
composite: CompositeAuthoringAdapter,
|
|
323
|
+
renderer: THREE.WebGLRenderer,
|
|
324
|
+
): Promise<() => void> {
|
|
325
|
+
const project = getCurrentProject();
|
|
326
|
+
if (!project) return () => {};
|
|
327
|
+
const rawManifest = await fetchRawGameManifest();
|
|
328
|
+
|
|
329
|
+
// Mirror design-time-layers' entry guard: while play is already running,
|
|
330
|
+
// mount nothing and wait for Stop's rebuild.
|
|
331
|
+
if (store.playState !== 'stopped') {
|
|
332
|
+
return rebuildWhenPlayStops(store);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const manifest = parseEditModeManifest(rawManifest);
|
|
336
|
+
if (!manifest) return () => {};
|
|
337
|
+
const threeRootId = resolveThreeRootId(declaredRoots(manifest));
|
|
338
|
+
const world = threeRootId ? rootById(manifest, threeRootId) : undefined;
|
|
339
|
+
if (!world) return () => {};
|
|
340
|
+
// WHAT THIS SESSION MOUNTS, and why it is not always the root's `entry`.
|
|
341
|
+
//
|
|
342
|
+
// Usually the two are the same file: `entry` IS the world component. A root
|
|
343
|
+
// that declares `world` says otherwise — its `entry` mounts the whole GAME
|
|
344
|
+
// (its own composition, HUD and loop), while the named component is the
|
|
345
|
+
// thing an AUTHOR edits. Edit mounts that; Play mounts the game
|
|
346
|
+
// (`ingest/deferred-ingest-play.ts`). Reading the fact here is what makes
|
|
347
|
+
// "a scene is a single root" true without this module knowing anything
|
|
348
|
+
// about WHY a given game has a shell — an ingested game's own App.tsx and a
|
|
349
|
+
// translated Unity port's scene HOST are the two shipped cases, and the
|
|
350
|
+
// second is why the field is not an ingest one: a Unity port's `entry`
|
|
351
|
+
// starts the built player on EditorBuildSettings index 0, which for the FPS
|
|
352
|
+
// microgame is a uGUI menu with three three-surface nodes and not one
|
|
353
|
+
// renderer among them. Edit opened on that and drew nothing at all.
|
|
354
|
+
const designEntry = world.world?.entry ?? world.entry;
|
|
355
|
+
const designExport = world.world?.export;
|
|
356
|
+
if (!designEntry || !/\.(tsx|jsx)$/.test(designEntry)) {
|
|
357
|
+
return () => {};
|
|
358
|
+
}
|
|
359
|
+
// These two independent graphs are the expensive local-development lane:
|
|
360
|
+
// the game's own entry and the editor's full native-Three authoring adapter.
|
|
361
|
+
// Start the latter here and await it beside the entry below. Keeping the
|
|
362
|
+
// adapter as a static import serialized both graphs on a cold browser even
|
|
363
|
+
// though neither depends on the other.
|
|
364
|
+
const sourceAdapterModule = import('./r3f-source-authoring-adapter');
|
|
365
|
+
const writeBackend = tierSourceWriteBackend();
|
|
366
|
+
|
|
367
|
+
let torndown = false;
|
|
368
|
+
let suspended = false;
|
|
369
|
+
let mounted: MountedThreeRoot | null = null;
|
|
370
|
+
let adapter: R3fSourceAuthoringAdapter | null = null;
|
|
371
|
+
let sceneAdopted = false;
|
|
372
|
+
let adoptedScene: THREE.Scene | null = null;
|
|
373
|
+
let remountQueued = false;
|
|
374
|
+
// One-shot guard for the transient-failure retry below.
|
|
375
|
+
let remountRetried = false;
|
|
376
|
+
let disposeHot: (() => void) | undefined;
|
|
377
|
+
let disposeDesignHost: (() => void) | undefined;
|
|
378
|
+
let unsubscribeSystemAdapters: (() => void) | undefined;
|
|
379
|
+
let unsubStore: (() => void) | undefined;
|
|
380
|
+
/** The mounted design world's own `Game` — the debug plane's source. */
|
|
381
|
+
let designGame: GameInternal | null = null;
|
|
382
|
+
/** The bag currently registered under the solo seat, or `null` when this
|
|
383
|
+
* session holds no seat. Doubles as the idempotence flag for the pair
|
|
384
|
+
* below — see this module's DEBUG PLANE OWNERSHIP note. */
|
|
385
|
+
let publishedSystems: SystemAdapters | null = null;
|
|
386
|
+
|
|
387
|
+
const worldId = world.id;
|
|
388
|
+
const entry = designEntry;
|
|
389
|
+
|
|
390
|
+
/** Take the solo `setActiveSystems` seat for the edit world. No-op unless a
|
|
391
|
+
* design world is mounted, this session is live, and play is stopped. */
|
|
392
|
+
const publishPlane = (): void => {
|
|
393
|
+
if (publishedSystems || !designGame) return;
|
|
394
|
+
if (torndown || suspended || store.playState !== 'stopped') return;
|
|
395
|
+
publishedSystems = designGame.systemAdapters;
|
|
396
|
+
setActiveSystems(publishedSystems);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** Release it. The ONE teardown path; idempotent. */
|
|
400
|
+
const withdrawPlane = (): void => {
|
|
401
|
+
if (!publishedSystems) return;
|
|
402
|
+
publishedSystems = null;
|
|
403
|
+
setActiveSystems(null);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const importEntry = async (): Promise<{
|
|
407
|
+
adapter: RootAdapter;
|
|
408
|
+
entryDebug: NativeDebugBinding | null;
|
|
409
|
+
entrySystems: NativeSystemsBinding | null;
|
|
410
|
+
components?: unknown;
|
|
411
|
+
} | null> => {
|
|
412
|
+
// PD-3: a design remount IS a new mount generation, so it opens a new
|
|
413
|
+
// mount epoch — and therefore its own realm value over that epoch. WHERE
|
|
414
|
+
// the entry comes from (dev `/@fs`, or the packaged project graph) is the
|
|
415
|
+
// realm's one answer; this session used to carry its own copy of it.
|
|
416
|
+
const realm = await activeRealmServices(project.rootPath, beginProjectMountEpoch());
|
|
417
|
+
// This whole session is scoped to a THREE root (`resolveThreeRootId`
|
|
418
|
+
// above), so the declared surface is known outright.
|
|
419
|
+
const { designModule: mod, rootModule } = await loadR3FDesignEntryModules(
|
|
420
|
+
realm,
|
|
421
|
+
worldId,
|
|
422
|
+
entry,
|
|
423
|
+
world.entry ?? entry,
|
|
424
|
+
);
|
|
425
|
+
// The manifest root entry owns this root's static debug/system surface,
|
|
426
|
+
// even when Edit mounts a narrower `world.world.entry` scene component.
|
|
427
|
+
// Harvesting the scene namespace silently dropped stable root-level slots
|
|
428
|
+
// (translated Unity physics is one) from the stopped design session.
|
|
429
|
+
const entryDebug = nativeDebugBindingFromEntryModule(worldId, rootModule);
|
|
430
|
+
const entrySystems = nativeSystemsBindingFromEntryModule(worldId, rootModule, 'three');
|
|
431
|
+
// WHAT that module means is `entry-adjudication.ts`'s one answer — the
|
|
432
|
+
// same one play mode and the runtime mount get, which is what keeps design
|
|
433
|
+
// mode from disagreeing with them about a world file. `world.export` names
|
|
434
|
+
// ONE component in a module that exports several (a game's `App.tsx`
|
|
435
|
+
// exporting `Scene` beside `Hud` and `App`); it is handed to the SAME
|
|
436
|
+
// resolver a default export is, and a name the module does not export is a
|
|
437
|
+
// NAMED failure there, never a silent fall-back to `default` (which for
|
|
438
|
+
// such a module would mount the whole game into the Scene view).
|
|
439
|
+
const adapter = await adjudicateThreeEntry(mod, worldId, {
|
|
440
|
+
...(designExport === undefined ? {} : { namedExport: designExport }),
|
|
441
|
+
entryPath: entry,
|
|
442
|
+
});
|
|
443
|
+
if (!adapter) {
|
|
444
|
+
throw unresolvedDesignEntryError(worldId, entry, Object.keys(mod));
|
|
445
|
+
}
|
|
446
|
+
// A named world component is one component out of its module; the module's
|
|
447
|
+
// OTHER exports are not this world's component table.
|
|
448
|
+
if (designExport !== undefined) return { adapter, entryDebug, entrySystems };
|
|
449
|
+
return {
|
|
450
|
+
adapter,
|
|
451
|
+
entryDebug,
|
|
452
|
+
entrySystems,
|
|
453
|
+
components: mod['components'] ?? mod['behaviors'],
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* A mounted design world and the host resources that must die WITH it.
|
|
459
|
+
*
|
|
460
|
+
* `mountRoot` hands this back rather than installing it, because a remount
|
|
461
|
+
* mounts the NEW world before tearing the OLD one down: assigning
|
|
462
|
+
* `disposeDesignHost` inside `mountRoot` (what it used to do) meant the
|
|
463
|
+
* teardown that followed disposed the *incoming* Game and leaked the
|
|
464
|
+
* outgoing one's window listeners. `game.dispose()` strips the debug
|
|
465
|
+
* registry, so with the plane published that mis-wiring showed up as a
|
|
466
|
+
* design session that answered nothing after the first source edit.
|
|
467
|
+
* Ownership is `adoptMount`'s, and only after `disposeMounted`.
|
|
468
|
+
*/
|
|
469
|
+
interface DesignMount {
|
|
470
|
+
root: MountedThreeRoot;
|
|
471
|
+
game: GameInternal;
|
|
472
|
+
disposeHost: () => void;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const mountRoot = async (
|
|
476
|
+
adapterExport: RootAdapter,
|
|
477
|
+
entryDebug: NativeDebugBinding | null,
|
|
478
|
+
entrySystems: NativeSystemsBinding | null,
|
|
479
|
+
): Promise<DesignMount> => {
|
|
480
|
+
const { host, game: hostGame, dispose: disposeHost } = createDesignHost(renderer);
|
|
481
|
+
let result: MountedThreeRoot;
|
|
482
|
+
try {
|
|
483
|
+
result = await adapterExport.mount(host);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
disposeHost();
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
if (result.kind !== 'three' || !isThreeScene(result.scene)) {
|
|
489
|
+
result.dispose();
|
|
490
|
+
disposeHost();
|
|
491
|
+
throw new Error(`entry adapter for world "${worldId}" did not mount a three scene`);
|
|
492
|
+
}
|
|
493
|
+
// This is a real Game shell, so its real root registry must own the mount.
|
|
494
|
+
// `game.systemAdapters` intentionally folds only registered roots; leaving
|
|
495
|
+
// the design mount detached stranded every React-effect contribution
|
|
496
|
+
// (Rapier, game debug providers, and future native systems) on
|
|
497
|
+
// `mounted.systems` even though the component registered successfully.
|
|
498
|
+
registerR3FDesignRoot(hostGame, adapterExport, result, worldId, entryDebug, entrySystems);
|
|
499
|
+
return { root: result, game: hostGame, disposeHost };
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/** Install a freshly-mounted design world as THE live one. The previous one
|
|
503
|
+
* must already be down (`disposeMounted`) — this overwrites its disposer. */
|
|
504
|
+
const adoptMount = (next: DesignMount): void => {
|
|
505
|
+
mounted = next.root;
|
|
506
|
+
designGame = next.game;
|
|
507
|
+
// Physics/debug contributions register from React effects, which may land
|
|
508
|
+
// after adapter.mount() returns. Keep the edit-mode plane and Inspector
|
|
509
|
+
// projection on the Game's CURRENT aggregate just as Play mode does; a
|
|
510
|
+
// one-time `game.systemAdapters` snapshot permanently reported those
|
|
511
|
+
// late contributions as implemented-empty.
|
|
512
|
+
unsubscribeSystemAdapters?.();
|
|
513
|
+
const adoptedGame = next.game;
|
|
514
|
+
unsubscribeSystemAdapters = adoptedGame.subscribeSystemAdapters?.(() => {
|
|
515
|
+
if (designGame !== adoptedGame) return;
|
|
516
|
+
const systems = adoptedGame.systemAdapters;
|
|
517
|
+
if (publishedSystems) {
|
|
518
|
+
publishedSystems = systems;
|
|
519
|
+
updateInstanceSystems(systems);
|
|
520
|
+
}
|
|
521
|
+
store.notifyIngestEdit();
|
|
522
|
+
});
|
|
523
|
+
// The design Game outlives `mount()` (the world holds its ctx) and must go
|
|
524
|
+
// down with the mount, or every HMR remount leaks another window-listener
|
|
525
|
+
// set and another debug registry.
|
|
526
|
+
disposeDesignHost = next.disposeHost;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const disposeMounted = (): void => {
|
|
530
|
+
// Before anything is disposed: a published bag must never outlive the
|
|
531
|
+
// registry behind it (`game.dispose()` strips it), or a remount would
|
|
532
|
+
// leave the relay reading a dead plane.
|
|
533
|
+
withdrawPlane();
|
|
534
|
+
unsubscribeSystemAdapters?.();
|
|
535
|
+
unsubscribeSystemAdapters = undefined;
|
|
536
|
+
designGame = null;
|
|
537
|
+
try {
|
|
538
|
+
mounted?.dispose();
|
|
539
|
+
} catch (err) {
|
|
540
|
+
editorConsole.error(`[r3f-design] dispose failed: ${err}`, 'authoring');
|
|
541
|
+
}
|
|
542
|
+
mounted = null;
|
|
543
|
+
try {
|
|
544
|
+
disposeDesignHost?.();
|
|
545
|
+
} catch (err) {
|
|
546
|
+
editorConsole.error(`[r3f-design] design host dispose failed: ${err}`, 'authoring');
|
|
547
|
+
}
|
|
548
|
+
disposeDesignHost = undefined;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const restoreEditorScene = (): void => {
|
|
552
|
+
if (!sceneAdopted) return;
|
|
553
|
+
sceneAdopted = false;
|
|
554
|
+
const scene = adoptedScene;
|
|
555
|
+
adoptedScene = null;
|
|
556
|
+
// Release OUR adoption specifically: at the deferred play hand-off (see
|
|
557
|
+
// the suspend deferral below) play has already adopted the live game
|
|
558
|
+
// scene OVER this one, and a blind exitPlayScene would pop PLAY's frame
|
|
559
|
+
// instead of ours (editor-store.releaseAdoptedScene).
|
|
560
|
+
if (scene) store.releaseAdoptedScene(scene);
|
|
561
|
+
else store.exitPlayScene();
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const suspendForPlay = (): void => {
|
|
565
|
+
suspended = true;
|
|
566
|
+
restoreEditorScene();
|
|
567
|
+
disposeMounted();
|
|
568
|
+
composite.replaceChild(
|
|
569
|
+
worldId,
|
|
570
|
+
new BoundaryAuthoringAdapter(
|
|
571
|
+
store,
|
|
572
|
+
boundaryInfo(world),
|
|
573
|
+
'R3F design session suspended by play mode — Stop restores it.',
|
|
574
|
+
),
|
|
575
|
+
);
|
|
576
|
+
store.notifyIngestEdit();
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const failToBoundary = (err: unknown): void => {
|
|
580
|
+
const message = reportDesignMountFailure(worldId, err);
|
|
581
|
+
composite.replaceChild(
|
|
582
|
+
worldId,
|
|
583
|
+
new BoundaryAuthoringAdapter(store, boundaryInfo(world), message),
|
|
584
|
+
);
|
|
585
|
+
store.notifyIngestEdit();
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------- initial mount
|
|
589
|
+
// Stall watchdog: the import/bundle step can HANG without throwing (realm
|
|
590
|
+
// services, the in-browser bundler, esbuild-wasm's own binary download on a
|
|
591
|
+
// slow network) — and a hang is invisible: the editor chrome runs at full
|
|
592
|
+
// FPS over an empty scene with a clean console. A human sat on exactly that
|
|
593
|
+
// for 2.5 minutes and gave up (runhuman pass 42). Narrate the wait so a
|
|
594
|
+
// stall is a diagnosable report, never silence.
|
|
595
|
+
const stallTimer = setTimeout(() => {
|
|
596
|
+
editorConsole.warn(
|
|
597
|
+
`[r3f-design] world "${worldId}" is still importing after 20s (${entry}). ` +
|
|
598
|
+
'The in-browser compiler or a module download may be stalled on a slow ' +
|
|
599
|
+
'connection — it keeps trying; reload the tab if nothing appears.',
|
|
600
|
+
'authoring',
|
|
601
|
+
);
|
|
602
|
+
}, 20_000);
|
|
603
|
+
try {
|
|
604
|
+
const [loaded, { R3fSourceAuthoringAdapter }, persistence] = await Promise.all([
|
|
605
|
+
importEntry(),
|
|
606
|
+
sourceAdapterModule,
|
|
607
|
+
writeBackend,
|
|
608
|
+
]);
|
|
609
|
+
clearTimeout(stallTimer);
|
|
610
|
+
if (!loaded) {
|
|
611
|
+
editorConsole.warn(
|
|
612
|
+
`[r3f-design] world "${worldId}" produced no design entry (${entry}) — ` +
|
|
613
|
+
'the scene stays empty. This is a bug worth reporting; reload the tab to retry.',
|
|
614
|
+
'authoring',
|
|
615
|
+
);
|
|
616
|
+
return () => {};
|
|
617
|
+
}
|
|
618
|
+
if (torndown) return () => {};
|
|
619
|
+
// Restart can enter Play while this design import is in flight.
|
|
620
|
+
if (store.playState !== 'stopped') return rebuildWhenPlayStops(store);
|
|
621
|
+
const first = await mountRoot(loaded.adapter, loaded.entryDebug, loaded.entrySystems);
|
|
622
|
+
adoptMount(first);
|
|
623
|
+
if (torndown || store.playState !== 'stopped') {
|
|
624
|
+
disposeMounted();
|
|
625
|
+
return torndown ? () => {} : rebuildWhenPlayStops(store);
|
|
626
|
+
}
|
|
627
|
+
adapter = new R3fSourceAuthoringAdapter(store, first.root.scene, {
|
|
628
|
+
worldId,
|
|
629
|
+
entryPath: entry,
|
|
630
|
+
writeBackend: persistence,
|
|
631
|
+
physics: () => nodeKeyedPhysics(designGame?.systemAdapters.physics),
|
|
632
|
+
});
|
|
633
|
+
composite.replaceChild(worldId, adapter);
|
|
634
|
+
// The hierarchy can become interactive as soon as the composite child is
|
|
635
|
+
// replaced, while this async mount is still completing. Preserve a user
|
|
636
|
+
// selection made in that window across enterPlayScene(), which clears the
|
|
637
|
+
// store selection while adopting the mounted scene. OID-signature ids are
|
|
638
|
+
// stable across the boundary, so only restore ids the new composite owns.
|
|
639
|
+
const selectedIds = selectionRemountHandoff.take(store, store.selectedEntityIds);
|
|
640
|
+
// The world's own colour pipeline travels WITH the adoption: this session
|
|
641
|
+
// mounted it against a renderer that draws nothing, so the declaration has
|
|
642
|
+
// to reach the viewport's renderer through the adoption instead (see
|
|
643
|
+
// `MountedThreeRoot.rendererConfig` and `EditorShellStore.adoptedImageConfig`).
|
|
644
|
+
store.enterPlayScene(first.root.scene, first.root.rendererConfig);
|
|
645
|
+
sceneAdopted = true;
|
|
646
|
+
adoptedScene = first.root.scene;
|
|
647
|
+
// FIRST LOOK. Adoption alone leaves the viewport on its construction-time
|
|
648
|
+
// default pose (`editor-viewport.ts`'s `camera.position.set(10, 10, 10)`
|
|
649
|
+
// looking at the origin), which is a statement about nothing: a
|
|
650
|
+
// world-scale world simply contains that point, and the reader's opening
|
|
651
|
+
// frame is the inside of whatever geometry happens to sit there. Measured
|
|
652
|
+
// on the racing game — the first look, and the doctor's own
|
|
653
|
+
// `01-first-look.png`, came back a flat field of canyon rock, with the
|
|
654
|
+
// live camera still reading exactly (10, 10, 10) -> (0, 0, 0).
|
|
655
|
+
//
|
|
656
|
+
// This is byte-for-byte the ask `ingest/mount-three-ingest-root.ts` makes
|
|
657
|
+
// after ITS adoption, and deliberately so: the answer was already built
|
|
658
|
+
// there. The viewport's auto-frame window seeds from the world's own
|
|
659
|
+
// camera when it has one — gated by `scene-framing.ts`'s
|
|
660
|
+
// `seededViewShowsWorld`, so a camera posed inside geometry is refused
|
|
661
|
+
// rather than adopted — and otherwise (and on refusal) falls back to
|
|
662
|
+
// `frameableContentBounds` framing, which always shows something. The
|
|
663
|
+
// lookup is passed LIVE, never resolved here: a design-mounted tree
|
|
664
|
+
// resolves its `<Suspense>` content after this point, so a camera read
|
|
665
|
+
// once at mount would miss the case this exists for.
|
|
666
|
+
//
|
|
667
|
+
// FIRST mount only, deliberately: `remount()` below re-adopts on every
|
|
668
|
+
// source edit, and re-framing there would yank the reader's camera out
|
|
669
|
+
// from under them on every save. A first look is opened once.
|
|
670
|
+
store.focusOnScene(() => pickGameCamera(null, first.root.scene));
|
|
671
|
+
const alive = selectedIds.filter((id) => composite.hierarchy.node(id) !== null);
|
|
672
|
+
if (alive.length > 0) store.selectMultiple(alive);
|
|
673
|
+
clearMountFailureReport(worldId);
|
|
674
|
+
publishPlane();
|
|
675
|
+
store.notifyIngestEdit();
|
|
676
|
+
editorConsole.log(
|
|
677
|
+
`[r3f-design] world "${worldId}" mounted for design-time authoring (${entry})`,
|
|
678
|
+
'authoring',
|
|
679
|
+
);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
clearTimeout(stallTimer);
|
|
682
|
+
// PD-1: report the failure and FALL THROUGH — a first mount that throws
|
|
683
|
+
// must not kill the session. This used to `return`, which skipped the
|
|
684
|
+
// `vgai:r3f-entry-update` subscription and the play-handoff store
|
|
685
|
+
// subscription installed below, so the world stayed a Boundary
|
|
686
|
+
// ("Unavailable" in the hierarchy) with a stale error report for the rest
|
|
687
|
+
// of the page's life: fixing the source recovered NOTHING, and the only
|
|
688
|
+
// exit was a hard reload of the editor tab. A failed remount already
|
|
689
|
+
// behaved correctly (`failToBoundary` inside `remount`, subscriptions
|
|
690
|
+
// intact) — the asymmetry was the whole defect. `remount` handles the
|
|
691
|
+
// no-adapter-yet state this leaves behind.
|
|
692
|
+
failToBoundary(err);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* A SLOW remount says where its time went; a fast one says nothing.
|
|
697
|
+
*
|
|
698
|
+
* An edit's whole cost is these three phases, and until this line existed
|
|
699
|
+
* the only way to attribute them was a human with a stopwatch reporting a
|
|
700
|
+
* single number (runhuman passes 96-99 measured 22-49s that way, and
|
|
701
|
+
* splitting it took offline reconstruction). `import` covers reading the
|
|
702
|
+
* project's sources and bundling them — `browser-transpile.ts` breaks that
|
|
703
|
+
* down further on the same threshold; `mount` is building the new React
|
|
704
|
+
* world; `settle` is waiting for an in-flight gesture and the write pipeline
|
|
705
|
+
* before the swap, which is deliberate and bounded but should be ~0 for an
|
|
706
|
+
* ordinary edit; `adopt` is everything AFTER the swap — disposing the
|
|
707
|
+
* outgoing world, adopting the scene, rebuilding the object map and the
|
|
708
|
+
* notify that re-renders every panel.
|
|
709
|
+
*
|
|
710
|
+
* `adopt` is here because the first version of this line stopped measuring
|
|
711
|
+
* at the swap, so a tester counting 3-5 seconds by hand saw NO line at all
|
|
712
|
+
* (runhuman pass 101): the phases it covered really were under the
|
|
713
|
+
* threshold, and the cost was in the tail it excluded. A partial measurement
|
|
714
|
+
* that reads as "fast" is worse than none.
|
|
715
|
+
*
|
|
716
|
+
* Threshold, not always-on: an edit that already feels instant does not need
|
|
717
|
+
* to narrate itself.
|
|
718
|
+
*/
|
|
719
|
+
const reportSlowRemount = (
|
|
720
|
+
id: string,
|
|
721
|
+
startedAt: number,
|
|
722
|
+
importedAt: number,
|
|
723
|
+
mountedAt: number,
|
|
724
|
+
settledAt: number,
|
|
725
|
+
): void => {
|
|
726
|
+
const total = performance.now() - startedAt;
|
|
727
|
+
if (total < 400) return;
|
|
728
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb, same channel as the stall notes above
|
|
729
|
+
console.info(
|
|
730
|
+
`[r3f-design] world "${id}" remounted in ${total.toFixed(0)}ms ` +
|
|
731
|
+
`(import ${(importedAt - startedAt).toFixed(0)}ms, ` +
|
|
732
|
+
`mount ${(mountedAt - importedAt).toFixed(0)}ms, ` +
|
|
733
|
+
`settle ${(settledAt - mountedAt).toFixed(0)}ms, ` +
|
|
734
|
+
`adopt ${(performance.now() - settledAt).toFixed(0)}ms)`,
|
|
735
|
+
);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------- remount
|
|
739
|
+
const remount = async (): Promise<void> => {
|
|
740
|
+
if (torndown || suspended) return;
|
|
741
|
+
const selectedIds = [...store.selectedEntityIds];
|
|
742
|
+
// Same stall watchdog as the initial mount, per stage: after a crashed
|
|
743
|
+
// mount the NEXT remount was observed to hang silently — no "mounted", no
|
|
744
|
+
// "failed" — leaving the world Unavailable with a clean console.
|
|
745
|
+
const startedAtWriteStamp = writeStamp;
|
|
746
|
+
const historyBusyAtStart = store.projectHistory?.getSnapshot().busy === true;
|
|
747
|
+
let stage: 'importing' | 'mounting' = 'importing';
|
|
748
|
+
const stallTimer = setTimeout(() => {
|
|
749
|
+
editorConsole.warn(
|
|
750
|
+
`[r3f-design] world "${worldId}" remount is still ${stage} after 20s (${entry}) — ` +
|
|
751
|
+
'it keeps trying; reload the tab if nothing appears.',
|
|
752
|
+
'authoring',
|
|
753
|
+
);
|
|
754
|
+
}, 20_000);
|
|
755
|
+
const remountStartedAt = performance.now();
|
|
756
|
+
let importedAt = remountStartedAt;
|
|
757
|
+
let mountedAt = remountStartedAt;
|
|
758
|
+
let settledAt = remountStartedAt;
|
|
759
|
+
try {
|
|
760
|
+
const loaded = await importEntry();
|
|
761
|
+
importedAt = performance.now();
|
|
762
|
+
if (!loaded || torndown || suspended) {
|
|
763
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
764
|
+
console.info(
|
|
765
|
+
`[r3f-design] world "${worldId}" remount abandoned after import (${!loaded ? 'no entry' : torndown ? 'torn down' : 'suspended'})`,
|
|
766
|
+
);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
stage = 'mounting';
|
|
770
|
+
const next = await mountRoot(loaded.adapter, loaded.entryDebug, loaded.entrySystems);
|
|
771
|
+
mountedAt = performance.now();
|
|
772
|
+
// THE SWAP WAITS FOR THE HAND — AND FOR THE HAND'S WRITE. Adopting a
|
|
773
|
+
// fresh world mid-drag moves the gesture's objects out from under it,
|
|
774
|
+
// and adopting between a release and its write landing snaps the object
|
|
775
|
+
// to PRE-drag source for a beat before the write's own remount corrects
|
|
776
|
+
// it — the "snap back, then it went back to where I released it" every
|
|
777
|
+
// rapid drag showed (runhuman passes 49/54/55). The build above ran in
|
|
778
|
+
// parallel; only the swap holds: first for the gesture, then for the
|
|
779
|
+
// project write pipeline to drain. If anything landed new source since
|
|
780
|
+
// this bundle was read, this mount is STALE — drop it and let the
|
|
781
|
+
// remount those writes queued deliver.
|
|
782
|
+
// SETTLE LOOP: a lock taken in the same task that released the previous
|
|
783
|
+
// one (pointer-up ends the drag lock; the gesture's write-hold begins
|
|
784
|
+
// immediately after) can race a waiter that already resolved — re-check
|
|
785
|
+
// both gates until one pass finds both quiet.
|
|
786
|
+
for (let settle = 0; settle < 50; settle += 1) {
|
|
787
|
+
await whenLiveGestureIdle();
|
|
788
|
+
await whenProjectHistoryIdle();
|
|
789
|
+
if (!liveGestureActive() && store.projectHistory?.getSnapshot().busy !== true) break;
|
|
790
|
+
}
|
|
791
|
+
settledAt = performance.now();
|
|
792
|
+
if (!torndown && !suspended && writeStamp !== startedAtWriteStamp) {
|
|
793
|
+
next.root.dispose();
|
|
794
|
+
next.disposeHost();
|
|
795
|
+
// AND SCHEDULE THE ONE THAT DELIVERS THEM. Dropping the stale mount
|
|
796
|
+
// used to just return, trusting the newer write's own scheduleRemount
|
|
797
|
+
// — and under a burst that trust did not hold: three library drops
|
|
798
|
+
// 0.28s apart produced ONE remount, all three writes reached the file
|
|
799
|
+
// and only the first reached the scene, permanently, with nothing in
|
|
800
|
+
// the console (Opus reproduction, 2026-09-01; at ~1s spacing the last
|
|
801
|
+
// of three was stranded, at ~2.4s all three landed — the window is the
|
|
802
|
+
// bundle+mount time). Convergence is this session's job: whatever the
|
|
803
|
+
// interleaving, the last write gets a mount.
|
|
804
|
+
scheduleRemount();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (torndown || suspended) {
|
|
808
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
809
|
+
console.info(
|
|
810
|
+
`[r3f-design] world "${worldId}" remount abandoned after mount (${torndown ? 'torn down' : 'suspended'})`,
|
|
811
|
+
);
|
|
812
|
+
next.root.dispose();
|
|
813
|
+
next.disposeHost();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
// READ THE USER'S SELECTION BEFORE THE SWAP. `restoreEditorScene()`
|
|
817
|
+
// releases this session's adoption, and `exitPlayScene` restores the
|
|
818
|
+
// selection snapshot the PREVIOUS adoption pushed — so a click made
|
|
819
|
+
// while this remount ran was already overwritten by the time the
|
|
820
|
+
// mid-remount guard below read the store, and the guard never fired:
|
|
821
|
+
// gizmo-move tree A, click tree B during the 2.5 s remount, and the
|
|
822
|
+
// selection snapped back to A 255 ms after the adopt (Opus
|
|
823
|
+
// reproduction on preview-b53, 2/2 with a non-reverting control; the
|
|
824
|
+
// human typed their Position X into the wrong tree — runhuman pass 128).
|
|
825
|
+
const selectionAtSwap = new Set(store.selectedEntityIds);
|
|
826
|
+
restoreEditorScene();
|
|
827
|
+
// The OUTGOING world goes down first, with its OWN host — then the
|
|
828
|
+
// incoming one is adopted. Reversing these disposes the incoming Game.
|
|
829
|
+
disposeMounted();
|
|
830
|
+
adoptMount(next);
|
|
831
|
+
if (adapter) {
|
|
832
|
+
adapter.adoptScene(next.root.scene);
|
|
833
|
+
// A FAILED remount left a Boundary in the composite (`failToBoundary`);
|
|
834
|
+
// a later successful one adopted the fresh scene into this adapter
|
|
835
|
+
// but never put the adapter back, so the hierarchy read "Unavailable"
|
|
836
|
+
// forever while the world had in fact remounted — what looked like a
|
|
837
|
+
// post-crash hang was this (measured after the crash auto-undo).
|
|
838
|
+
const current = composite
|
|
839
|
+
.childAdapters()
|
|
840
|
+
.find((child) => child.worldId === worldId)?.adapter;
|
|
841
|
+
if (current !== adapter) {
|
|
842
|
+
composite.replaceChild(worldId, adapter);
|
|
843
|
+
editorConsole.log(
|
|
844
|
+
`[r3f-design] world "${worldId}" recovered and remounted for design-time authoring (${entry})`,
|
|
845
|
+
'authoring',
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
// PD-1 recovery leg: the FIRST mount failed, so the composite still
|
|
850
|
+
// holds this world's Boundary and no source-authoring adapter exists
|
|
851
|
+
// yet. Build it now — otherwise the world would mount invisibly
|
|
852
|
+
// (scene adopted, hierarchy still "Unavailable"), which is the same
|
|
853
|
+
// dead end from the user's side.
|
|
854
|
+
const [{ R3fSourceAuthoringAdapter }, persistence] = await Promise.all([
|
|
855
|
+
sourceAdapterModule,
|
|
856
|
+
writeBackend,
|
|
857
|
+
]);
|
|
858
|
+
adapter = new R3fSourceAuthoringAdapter(store, next.root.scene, {
|
|
859
|
+
worldId,
|
|
860
|
+
entryPath: entry,
|
|
861
|
+
writeBackend: persistence,
|
|
862
|
+
physics: () => nodeKeyedPhysics(designGame?.systemAdapters.physics),
|
|
863
|
+
});
|
|
864
|
+
composite.replaceChild(worldId, adapter);
|
|
865
|
+
editorConsole.log(
|
|
866
|
+
`[r3f-design] world "${worldId}" recovered and mounted for design-time authoring (${entry})`,
|
|
867
|
+
'authoring',
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
// A click landing DURING the remount is the newer intent: restoring the
|
|
871
|
+
// ids captured at write time stomped it — a human coloring box A then
|
|
872
|
+
// clicking box B watched the selection "jump back to the one I just
|
|
873
|
+
// colored" on every color edit (runhuman pass 35). Only restore when
|
|
874
|
+
// the user did not select something else while the remount ran.
|
|
875
|
+
const selectionChangedMidRemount =
|
|
876
|
+
selectionAtSwap.size > 0 &&
|
|
877
|
+
(selectionAtSwap.size !== selectedIds.length ||
|
|
878
|
+
selectedIds.some((id) => !selectionAtSwap.has(id)));
|
|
879
|
+
store.enterPlayScene(next.root.scene, next.root.rendererConfig);
|
|
880
|
+
sceneAdopted = true;
|
|
881
|
+
adoptedScene = next.root.scene;
|
|
882
|
+
// W4e — selection survives: oid-signature ids re-resolve onto the fresh
|
|
883
|
+
// objects (exitPlayScene cleared the store selection; restore it).
|
|
884
|
+
const restoreIds = selectionChangedMidRemount ? [...selectionAtSwap] : selectedIds;
|
|
885
|
+
const alive = restoreIds.filter((id) => adapter?.hierarchy.node(id) !== null);
|
|
886
|
+
if (alive.length > 0) store.selectMultiple(alive);
|
|
887
|
+
// PD-1: this world is mounted again — retract its failure report so the
|
|
888
|
+
// status item (and `vgai status`) can go back to healthy.
|
|
889
|
+
clearMountFailureReport(worldId);
|
|
890
|
+
// The plane follows the FRESH game (`disposeMounted` withdrew the old
|
|
891
|
+
// one's), so a stat added by the edit that triggered this remount is
|
|
892
|
+
// readable without entering play.
|
|
893
|
+
publishPlane();
|
|
894
|
+
store.notifyIngestEdit();
|
|
895
|
+
reportSlowRemount(worldId, remountStartedAt, importedAt, mountedAt, settledAt);
|
|
896
|
+
refreshRevisions.mounted();
|
|
897
|
+
clearTimeout(revisionFallback);
|
|
898
|
+
remountRetried = false;
|
|
899
|
+
} catch (err) {
|
|
900
|
+
failToBoundary(err);
|
|
901
|
+
// ONE automatic retry per failure burst: a read racing a write commit
|
|
902
|
+
// clears within milliseconds, and
|
|
903
|
+
// without this a single unlucky remount stranded the author on an
|
|
904
|
+
// unmounted world until a manual page reload (runhuman passes 19/21).
|
|
905
|
+
// A persistent failure fails again immediately — and if the editor's
|
|
906
|
+
// own edit caused it, that edit is undone (below) rather than left in
|
|
907
|
+
// source, where a reload would fail the same way.
|
|
908
|
+
if (!remountRetried && !torndown && !suspended) {
|
|
909
|
+
remountRetried = true;
|
|
910
|
+
setTimeout(() => {
|
|
911
|
+
if (!torndown && !suspended) scheduleRemount();
|
|
912
|
+
}, 400);
|
|
913
|
+
} else if (!torndown && !suspended) {
|
|
914
|
+
// Only a TRUSTED failure may trigger the auto-undo: one whose bundle
|
|
915
|
+
// was built from settled source (no write landed during the mount,
|
|
916
|
+
// no write in flight when it started). Under a rapid drag burst a
|
|
917
|
+
// half-written module can evaluate and throw INSIDE the fiber — the
|
|
918
|
+
// message says "fiber crashed" but the edit is fine, and undoing it
|
|
919
|
+
// reverted a good drag about one burst in seven (runhuman pass 58).
|
|
920
|
+
// An untrusted failure just remounts again once things settle.
|
|
921
|
+
if (writeStamp === startedAtWriteStamp && !historyBusyAtStart) {
|
|
922
|
+
void undoCrashingEdit(err);
|
|
923
|
+
} else {
|
|
924
|
+
remountRetried = false;
|
|
925
|
+
scheduleRemount();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
} finally {
|
|
929
|
+
clearTimeout(stallTimer);
|
|
930
|
+
// A write that landed while this mount was adopting is not covered by
|
|
931
|
+
// the stale check above (it runs before the adopt): same convergence
|
|
932
|
+
// rule, checked once more on the way out.
|
|
933
|
+
if (!torndown && !suspended && writeStamp !== startedAtWriteStamp) scheduleRemount();
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
/** Resolve when the project history (the sha-guarded write pipeline) has no
|
|
938
|
+
* in-flight work — bounded, so a wedged pipeline degrades to the stale-drop
|
|
939
|
+
* guard instead of holding the swap forever. */
|
|
940
|
+
const whenProjectHistoryIdle = async (): Promise<void> => {
|
|
941
|
+
const history = store.projectHistory;
|
|
942
|
+
if (!history) return;
|
|
943
|
+
const deadline = Date.now() + 10_000;
|
|
944
|
+
while (history.getSnapshot().busy && Date.now() < deadline) {
|
|
945
|
+
await new Promise<void>((resolve) => {
|
|
946
|
+
const timer = setTimeout(resolve, 250);
|
|
947
|
+
const unsubscribe = history.subscribe(() => {
|
|
948
|
+
clearTimeout(timer);
|
|
949
|
+
unsubscribe();
|
|
950
|
+
resolve();
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* A source edit that crashes the world must not stay in source: a human
|
|
958
|
+
* set a grid size the component could not draw, the world failed, and the
|
|
959
|
+
* failure was still there after a reload — nothing they could reach undid
|
|
960
|
+
* it (runhuman pass 48). When the remount fails persistently and the
|
|
961
|
+
* project's newest history entry is seconds old, undo it and say so. The
|
|
962
|
+
* undo is sha-guarded, so a file changed by anything else refuses rather
|
|
963
|
+
* than clobbers; an unrelated failure with no recent edit leaves history
|
|
964
|
+
* alone.
|
|
965
|
+
*/
|
|
966
|
+
const undoCrashingEdit = async (err: unknown): Promise<void> => {
|
|
967
|
+
// ONLY a genuine world crash earns an auto-undo: the world's module
|
|
968
|
+
// mounted and its render threw ("fiber crashed"). A bundling/read failure
|
|
969
|
+
// under a rapid write burst is a TRANSIENT (the import raced the next
|
|
970
|
+
// write), and undoing there silently reverted the user's own drag seconds
|
|
971
|
+
// after release — "they move without us doing anything" (runhuman pass
|
|
972
|
+
// 57's multi-select snap-backs were this feature misfiring, not the
|
|
973
|
+
// gesture pipeline). Transients converge on the next scheduled remount.
|
|
974
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
975
|
+
if (!message.includes('fiber crashed')) return;
|
|
976
|
+
// AN ASSET THAT WOULD NOT LOAD IS NOT THE EDIT'S FAULT. A render that
|
|
977
|
+
// threw because a project file came back as HTML or failed to fetch
|
|
978
|
+
// crashed the fiber all the same,
|
|
979
|
+
// and undoing the author's last edit for it reverted every edit of a
|
|
980
|
+
// session while the real cause stayed (runhuman pass 138: the crash at
|
|
981
|
+
// t+0, then "everything reverted on its own"). Name the asset instead;
|
|
982
|
+
// the late-claim path in `storage-served-game-files.ts` remounts.
|
|
983
|
+
if (/Could not load |Failed to fetch|Unexpected token '<'|not valid JSON/.test(message)) {
|
|
984
|
+
editorConsole.error(
|
|
985
|
+
`World "${worldId}" failed to load a project asset (${message}). The edit is kept; ` +
|
|
986
|
+
'the world remounts once the asset can be served.',
|
|
987
|
+
'authoring',
|
|
988
|
+
);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const history = store.projectHistory;
|
|
992
|
+
if (!history) return;
|
|
993
|
+
const snapshot = history.getSnapshot();
|
|
994
|
+
const last = snapshot.canUndo ? snapshot.transactions[snapshot.cursor - 1] : undefined;
|
|
995
|
+
if (!last || Date.now() - last.timestamp > 15_000) {
|
|
996
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
997
|
+
console.info(
|
|
998
|
+
`[r3f-design] world "${worldId}" crash auto-undo skipped (${!last ? (snapshot.canUndo ? 'no entry' : `cannot undo: busy=${snapshot.busy} blocked=${snapshot.blocked}`) : 'last edit too old'})`,
|
|
999
|
+
);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const undone = await history.undo().catch(() => false);
|
|
1003
|
+
if (!undone) {
|
|
1004
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
1005
|
+
console.info(`[r3f-design] world "${worldId}" crash auto-undo refused by history`);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
remountRetried = false;
|
|
1009
|
+
editorConsole.error(
|
|
1010
|
+
`Undid “${last.label}”: that edit crashed world "${worldId}" (${
|
|
1011
|
+
err instanceof Error ? err.message : String(err)
|
|
1012
|
+
}). The previous source is restored.`,
|
|
1013
|
+
'authoring',
|
|
1014
|
+
);
|
|
1015
|
+
scheduleRemount();
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
/** Bumped on every source-change signal; a mount that started before the
|
|
1019
|
+
* latest bump was built from stale bytes. */
|
|
1020
|
+
let writeStamp = 0;
|
|
1021
|
+
const refreshRevisions = new SourceRefreshRevisions();
|
|
1022
|
+
let revisionFallback: ReturnType<typeof setTimeout> | undefined;
|
|
1023
|
+
|
|
1024
|
+
const scheduleRemount = (): void => {
|
|
1025
|
+
if (torndown || suspended) {
|
|
1026
|
+
// Diagnostic, not noise: a remount request that lands on a torn-down or
|
|
1027
|
+
// suspended session is the one state a "world stays Unavailable"
|
|
1028
|
+
// report cannot explain from the console otherwise.
|
|
1029
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
1030
|
+
console.info(
|
|
1031
|
+
`[r3f-design] world "${worldId}" remount skipped: ${torndown ? 'session torn down' : 'suspended for play'}`,
|
|
1032
|
+
);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (remountQueued) return;
|
|
1036
|
+
remountQueued = true;
|
|
1037
|
+
// Small debounce: one remount per write burst (a gesture can touch the
|
|
1038
|
+
// file more than once in quick succession).
|
|
1039
|
+
setTimeout(() => {
|
|
1040
|
+
remountQueued = false;
|
|
1041
|
+
void remount();
|
|
1042
|
+
}, 80);
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const stopAssetReload = onAssetReload(() => {
|
|
1046
|
+
writeStamp += 1;
|
|
1047
|
+
scheduleRemount();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const hot = import.meta.hot;
|
|
1051
|
+
if (hot) {
|
|
1052
|
+
const onUpdate = (): void => {
|
|
1053
|
+
writeStamp += 1;
|
|
1054
|
+
scheduleRemount();
|
|
1055
|
+
};
|
|
1056
|
+
const onSource = (source: RefreshSource): void => refreshRevisions.source(source);
|
|
1057
|
+
const onRefreshed = async (event: {
|
|
1058
|
+
updates: { acceptedPath: string; timestamp: number; explicitImportRequired?: boolean }[];
|
|
1059
|
+
}): Promise<void> => {
|
|
1060
|
+
if (torndown || suspended) return;
|
|
1061
|
+
const relevant = event.updates.filter((update) => refreshRevisions.matches(update));
|
|
1062
|
+
if (!relevant.length) return;
|
|
1063
|
+
if (
|
|
1064
|
+
!mounted ||
|
|
1065
|
+
composite.childAdapters().find((child) => child.worldId === worldId)?.adapter !== adapter
|
|
1066
|
+
) {
|
|
1067
|
+
// Fast Refresh cannot adopt a root that failed its initial mount.
|
|
1068
|
+
scheduleRemount();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const results = await Promise.allSettled(
|
|
1072
|
+
relevant.map(
|
|
1073
|
+
(update) =>
|
|
1074
|
+
import(/* @vite-ignore */ viteUpdateImportPath(update, import.meta.env.BASE_URL)),
|
|
1075
|
+
),
|
|
1076
|
+
);
|
|
1077
|
+
// Every mounted variant must evaluate. A successful sibling does not
|
|
1078
|
+
// acknowledge a failed instance of the same source file.
|
|
1079
|
+
if (results.every((result) => result.status === 'fulfilled'))
|
|
1080
|
+
refreshRevisions.complete(relevant);
|
|
1081
|
+
if (!refreshRevisions.needsRemount()) clearTimeout(revisionFallback);
|
|
1082
|
+
// The native adapter observes child additions/removals. Property-only
|
|
1083
|
+
// refreshes still need an inspector/viewport notification.
|
|
1084
|
+
store.notifyIngestEdit();
|
|
1085
|
+
};
|
|
1086
|
+
hot.on('vgai:r3f-entry-update', onUpdate);
|
|
1087
|
+
hot.on('vgai:r3f-refresh-source', onSource);
|
|
1088
|
+
hot.on('vite:afterUpdate', onRefreshed);
|
|
1089
|
+
disposeHot = () => {
|
|
1090
|
+
hot.off('vgai:r3f-entry-update', onUpdate);
|
|
1091
|
+
hot.off('vgai:r3f-refresh-source', onSource);
|
|
1092
|
+
hot.off('vite:afterUpdate', onRefreshed);
|
|
1093
|
+
clearTimeout(revisionFallback);
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Collaboration is the reliable fallback when a share tunnel loses Vite's
|
|
1098
|
+
// websocket. Suppress only revisions whose exact bytes completed HMR.
|
|
1099
|
+
let lastRevision = collaborationSnapshot()?.revision ?? 0;
|
|
1100
|
+
const disposeRevisionSub = subscribeCollaborationRevision((revision) => {
|
|
1101
|
+
writeStamp += 1;
|
|
1102
|
+
const snapshot = collaborationSnapshot();
|
|
1103
|
+
const revisions = snapshot?.revisions.filter((r) => r.revision > lastRevision) ?? [];
|
|
1104
|
+
if (revisions.length !== revision - lastRevision) refreshRevisions.missing();
|
|
1105
|
+
lastRevision = revision;
|
|
1106
|
+
const resources = revisions.flatMap((r) => r.resources);
|
|
1107
|
+
refreshRevisions.revision(resources);
|
|
1108
|
+
if (resources.length && !refreshRevisions.needsRemount()) return;
|
|
1109
|
+
// SSE can arrive before or after Vite. Give the matching update a bounded
|
|
1110
|
+
// chance to finish; a dropped/disconnected socket retains cold recovery.
|
|
1111
|
+
clearTimeout(revisionFallback);
|
|
1112
|
+
revisionFallback = setTimeout(() => {
|
|
1113
|
+
if (resources.length && !refreshRevisions.needsRemount()) return;
|
|
1114
|
+
writeStamp += 1;
|
|
1115
|
+
scheduleRemount();
|
|
1116
|
+
}, 2_000);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// ------------------------------------------------------------ play handoff
|
|
1120
|
+
let stopRebuildRequested = false;
|
|
1121
|
+
let suspendQueued = false;
|
|
1122
|
+
unsubStore = store.subscribe(() => {
|
|
1123
|
+
if (torndown) return;
|
|
1124
|
+
// Seat handover, BEFORE the deferred visual suspend below: play mode
|
|
1125
|
+
// registers under its own mount id, so holding both seats would make an
|
|
1126
|
+
// unaddressed `vgai eval` ambiguous. This fires at
|
|
1127
|
+
// `store.setPlayState('playing')`, which precedes play's own
|
|
1128
|
+
// `setActiveSystems` — so the two never overlap in either direction.
|
|
1129
|
+
if (store.playState === 'stopped') publishPlane();
|
|
1130
|
+
else withdrawPlane();
|
|
1131
|
+
if (!suspended && !suspendQueued && store.playState !== 'stopped') {
|
|
1132
|
+
// Keep the adopted design scene alive UNDER the play-entry transition.
|
|
1133
|
+
// Suspending at the playState flip swapped the viewport back to the
|
|
1134
|
+
// placeholder editor scene mid-flight — the whole screen flashed the
|
|
1135
|
+
// scene's default clear color until the cross-fade caught up. The
|
|
1136
|
+
// transition settling (cross-fade done, or torn down by an early Stop)
|
|
1137
|
+
// is the correct hand-off point; the guards re-check state because
|
|
1138
|
+
// settle can also mean "play already ended" — in that case the design
|
|
1139
|
+
// session simply stays live, no suspend/rebuild churn at all.
|
|
1140
|
+
suspendQueued = true;
|
|
1141
|
+
onPlayTransitionSettled(() => {
|
|
1142
|
+
suspendQueued = false;
|
|
1143
|
+
if (torndown || suspended || store.playState === 'stopped') return;
|
|
1144
|
+
suspendForPlay();
|
|
1145
|
+
});
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (suspended && !stopRebuildRequested && store.playState === 'stopped') {
|
|
1149
|
+
stopRebuildRequested = true;
|
|
1150
|
+
queueEditModeRebuild();
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
return () => {
|
|
1155
|
+
torndown = true;
|
|
1156
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb for a silent-stall report
|
|
1157
|
+
console.info(`[r3f-design] world "${worldId}" design session torn down`);
|
|
1158
|
+
withdrawPlane();
|
|
1159
|
+
disposeHot?.();
|
|
1160
|
+
stopAssetReload();
|
|
1161
|
+
disposeRevisionSub();
|
|
1162
|
+
clearTimeout(revisionFallback);
|
|
1163
|
+
unsubStore?.();
|
|
1164
|
+
// Normal design-session replacement (including Classic/Glass composition
|
|
1165
|
+
// changes) should preserve editor-global selection. Play suspension is a
|
|
1166
|
+
// different scene lifecycle and deliberately keeps exitPlayScene's normal
|
|
1167
|
+
// clearing semantics.
|
|
1168
|
+
if (!suspended && store.playState === 'stopped' && store.selectedEntityIds.size > 0) {
|
|
1169
|
+
selectionRemountHandoff.remember(store, store.selectedEntityIds);
|
|
1170
|
+
}
|
|
1171
|
+
restoreEditorScene();
|
|
1172
|
+
disposeMounted();
|
|
1173
|
+
};
|
|
1174
|
+
}
|