diff --git a/editor/AGENTS.md b/editor/AGENTS.md index 5e65c58c..b6910c77 100644 --- a/editor/AGENTS.md +++ b/editor/AGENTS.md @@ -12,6 +12,9 @@ The primary product line is still runtime/product loop closure: - extend the bound `EditorRuntimeCoordinator` instead of adding panel-local shortcuts for `run.*`, scene document commands, or project scene opens +- keep play mode transactional: `EditorRuntimeCoordinator` must enter play + through `EditorSceneRuntime::BeginPlaySession`, and `RuntimeLoop` must run + the play-session runtime scene rather than the editable document scene - bind a real script assembly builder behind `scripts.*`; until then the coordinator must keep the command honestly disabled - keep `Game`, `Scene`, `Inspector`, `Selection`, and `Console` coherent across @@ -51,7 +54,11 @@ Rules: - `GameViewportFeature`, `GameViewportRenderService`, and related tests exist. `EditorRuntimeCoordinator` is now the app-level owner for play-mode command - routing and uses `RuntimeLoop` for the active scene. + routing and uses `RuntimeLoop` for the active play-session scene. +- Play mode is a scene transaction. The engine scene backend snapshots the + editable scene, replaces it with a runtime scene for play/step, and restores + the editable scene when the play session ends. Do not start runtime playback + directly from `EditorSceneRuntime::GetActiveScene()`. - `EditorHostCommandBridge` delegates `file.*`, `run.*`, and `scripts.*` to a bound runtime owner. If no owner is bound, it must continue exposing honest disabled messages. @@ -85,6 +92,8 @@ Rules: - no panel-local shortcut that bypasses the intended runtime owner - no scene-open side channel in `EditorProjectRuntime`; project UI must call the panel service request hook +- no play-mode path that mutates the editable scene in place or skips + `EditorScenePlaySession` ## Good Entry Points @@ -117,6 +126,7 @@ cmake --build build --config Debug --target XCEditor Relevant focused tests include: - `test_editor_host_command_bridge.cpp` +- `test_editor_runtime_coordinator.cpp` - `test_game_viewport_runtime.cpp` - `test_project_panel.cpp` - `test_scene_viewport_render_plan.cpp` diff --git a/editor/app/Core/Scene/EditorSceneBackend.h b/editor/app/Core/Scene/EditorSceneBackend.h index 9b935129..9c7cc04f 100644 --- a/editor/app/Core/Scene/EditorSceneBackend.h +++ b/editor/app/Core/Scene/EditorSceneBackend.h @@ -381,6 +381,13 @@ struct EditorSceneViewportSelectionSnapshot { } }; +class EditorScenePlaySession { +public: + virtual ~EditorScenePlaySession() = default; + + [[nodiscard]] virtual ::XCEngine::Components::Scene* GetRuntimeScene() const = 0; +}; + class EditorSceneBackend { public: virtual ~EditorSceneBackend() = default; @@ -392,6 +399,9 @@ public: virtual bool OpenSceneAsset(const std::filesystem::path& scenePath) = 0; virtual bool SaveActiveScene(const std::filesystem::path& scenePath) = 0; virtual ::XCEngine::Components::Scene* GetActiveScene() const = 0; + virtual std::unique_ptr BeginPlaySession() { + return nullptr; + } virtual std::optional GetObjectSnapshot( std::string_view itemId) const = 0; virtual bool AddComponent( diff --git a/editor/app/Services/Runtime/EditorRuntimeCoordinator.cpp b/editor/app/Services/Runtime/EditorRuntimeCoordinator.cpp index 91d1adbe..32cf1772 100644 --- a/editor/app/Services/Runtime/EditorRuntimeCoordinator.cpp +++ b/editor/app/Services/Runtime/EditorRuntimeCoordinator.cpp @@ -52,6 +52,10 @@ std::string ResolveSceneDisplayName(const std::filesystem::path& scenePath) { } // namespace +EditorRuntimeCoordinator::~EditorRuntimeCoordinator() { + Shutdown(); +} + void EditorRuntimeCoordinator::Initialize( EditorSession& session, EditorSceneRuntime& sceneRuntime, @@ -76,6 +80,7 @@ void EditorRuntimeCoordinator::Initialize( void EditorRuntimeCoordinator::Shutdown() { m_runtimeLoop.Stop(); + m_playSession.reset(); if (m_session != nullptr) { m_session->runtimeMode = EditorRuntimeMode::Edit; } @@ -379,12 +384,32 @@ bool EditorRuntimeCoordinator::IsPlayModeActive() const { return IsReady() && m_session->runtimeMode != EditorRuntimeMode::Edit; } -bool EditorRuntimeCoordinator::StartPlayMode() { +bool EditorRuntimeCoordinator::EnsurePlaySession() { if (!HasActiveScene()) { return false; } - m_runtimeLoop.Start(m_sceneRuntime->GetActiveScene()); + if (m_playSession != nullptr && + m_playSession->GetRuntimeScene() != nullptr) { + return true; + } + + m_playSession = m_sceneRuntime->BeginPlaySession(); + if (m_playSession == nullptr || + m_playSession->GetRuntimeScene() == nullptr) { + m_playSession.reset(); + return false; + } + + return true; +} + +bool EditorRuntimeCoordinator::StartPlayMode() { + if (!EnsurePlaySession()) { + return false; + } + + m_runtimeLoop.Start(m_playSession->GetRuntimeScene()); m_session->runtimeMode = EditorRuntimeMode::Play; m_lastFrameTickTime = std::chrono::steady_clock::now(); return m_runtimeLoop.IsRunning(); @@ -396,6 +421,8 @@ bool EditorRuntimeCoordinator::StopPlayMode() { } m_runtimeLoop.Stop(); + m_playSession.reset(); + m_sceneRuntime->RefreshScene(); m_session->runtimeMode = EditorRuntimeMode::Edit; return true; } @@ -427,7 +454,11 @@ bool EditorRuntimeCoordinator::StepPlayMode() { } if (!m_runtimeLoop.IsRunning()) { - m_runtimeLoop.Start(m_sceneRuntime->GetActiveScene()); + if (!EnsurePlaySession()) { + return false; + } + + m_runtimeLoop.Start(m_playSession->GetRuntimeScene()); } m_runtimeLoop.StepFrame(); diff --git a/editor/app/Services/Runtime/EditorRuntimeCoordinator.h b/editor/app/Services/Runtime/EditorRuntimeCoordinator.h index 5f8d3597..6b5a0ef8 100644 --- a/editor/app/Services/Runtime/EditorRuntimeCoordinator.h +++ b/editor/app/Services/Runtime/EditorRuntimeCoordinator.h @@ -8,12 +8,14 @@ #include #include #include +#include #include #include namespace XCEngine::UI::Editor::App { class EditorProjectRuntime; +class EditorScenePlaySession; class EditorSceneRuntime; struct EditorSession; @@ -21,6 +23,7 @@ class EditorRuntimeCoordinator final : public EditorHostCommandBridge::RuntimeCommandOwner { public: EditorRuntimeCoordinator() = default; + ~EditorRuntimeCoordinator(); EditorRuntimeCoordinator(const EditorRuntimeCoordinator&) = delete; EditorRuntimeCoordinator& operator=(const EditorRuntimeCoordinator&) = delete; @@ -54,6 +57,7 @@ private: bool IsReady() const; bool HasActiveScene() const; bool IsPlayModeActive() const; + bool EnsurePlaySession(); bool StartPlayMode(); bool StopPlayMode(); bool PausePlayMode(); @@ -69,6 +73,7 @@ private: EditorRuntimePaths m_runtimePaths = {}; ::XCEngine::Components::RuntimeLoop m_runtimeLoop{ ::XCEngine::Components::RuntimeLoop::Settings{} }; + std::unique_ptr m_playSession = {}; std::chrono::steady_clock::time_point m_lastFrameTickTime = {}; std::uint64_t m_lastCleanSceneContentRevision = 0u; std::uint64_t m_lastObservedSceneContentRevision = 0u; diff --git a/editor/app/Services/Scene/EditorSceneRuntime.cpp b/editor/app/Services/Scene/EditorSceneRuntime.cpp index 46101394..c969d3c9 100644 --- a/editor/app/Services/Scene/EditorSceneRuntime.cpp +++ b/editor/app/Services/Scene/EditorSceneRuntime.cpp @@ -396,6 +396,12 @@ bool EditorSceneRuntime::SaveScene(const std::filesystem::path& scenePath) { return m_backend != nullptr ? m_backend->GetActiveScene() : nullptr; } +std::unique_ptr EditorSceneRuntime::BeginPlaySession() { + return m_backend != nullptr + ? m_backend->BeginPlaySession() + : nullptr; +} + bool EditorSceneRuntime::RenameGameObject( std::string_view itemId, std::string_view newName) { diff --git a/editor/app/Services/Scene/EditorSceneRuntime.h b/editor/app/Services/Scene/EditorSceneRuntime.h index 28c10fd2..c511c7db 100644 --- a/editor/app/Services/Scene/EditorSceneRuntime.h +++ b/editor/app/Services/Scene/EditorSceneRuntime.h @@ -73,6 +73,7 @@ public: bool OpenSceneAsset(const std::filesystem::path& scenePath); bool SaveScene(const std::filesystem::path& scenePath); ::XCEngine::Components::Scene* GetActiveScene() const; + std::unique_ptr BeginPlaySession(); bool RenameGameObject( std::string_view itemId, diff --git a/editor/app/Services/Scene/EngineEditorSceneBackend.cpp b/editor/app/Services/Scene/EngineEditorSceneBackend.cpp index 31d9098a..83a2e6ed 100644 --- a/editor/app/Services/Scene/EngineEditorSceneBackend.cpp +++ b/editor/app/Services/Scene/EngineEditorSceneBackend.cpp @@ -96,6 +96,89 @@ std::string ResolveUniqueSceneName( static_cast(sceneManager.GetAllScenes().size() + 1u)); } +bool IsSceneLoaded( + const SceneManager& sceneManager, + const Scene* scene) { + if (scene == nullptr) { + return false; + } + + const std::vector scenes = sceneManager.GetAllScenes(); + return std::find(scenes.begin(), scenes.end(), scene) != scenes.end(); +} + +Scene* CreateSceneFromSerializedSnapshot( + SceneManager& sceneManager, + ResourceManager& resourceManager, + std::string_view requestedName, + const std::string& serializedScene) { + const std::string sceneName = + ResolveUniqueSceneName(sceneManager, requestedName); + Scene* scene = sceneManager.CreateScene(sceneName); + if (scene == nullptr) { + return nullptr; + } + + { + ResourceManager::ScopedDeferredSceneLoad deferredSceneLoad(resourceManager); + scene->DeserializeFromString(serializedScene); + } + sceneManager.SetActiveScene(scene); + return scene; +} + +class EngineEditorScenePlaySession final : public EditorScenePlaySession { +public: + EngineEditorScenePlaySession( + SceneManager& sceneManager, + ResourceManager& resourceManager, + Scene* runtimeScene, + std::string editSceneName, + std::string editSceneSnapshot) + : m_sceneManager(sceneManager) + , m_resourceManager(resourceManager) + , m_runtimeScene(runtimeScene) + , m_editSceneName(std::move(editSceneName)) + , m_editSceneSnapshot(std::move(editSceneSnapshot)) {} + + ~EngineEditorScenePlaySession() override { + RestoreEditScene(); + } + + Scene* GetRuntimeScene() const override { + return m_runtimeScene; + } + +private: + void RestoreEditScene() { + if (m_restored) { + return; + } + + if (IsSceneLoaded(m_sceneManager, m_runtimeScene)) { + m_sceneManager.UnloadScene(m_runtimeScene); + } + m_runtimeScene = nullptr; + + if (!m_editSceneSnapshot.empty()) { + CreateSceneFromSerializedSnapshot( + m_sceneManager, + m_resourceManager, + m_editSceneName, + m_editSceneSnapshot); + } + + m_restored = true; + } + + SceneManager& m_sceneManager; + ResourceManager& m_resourceManager; + Scene* m_runtimeScene = nullptr; + std::string m_editSceneName = {}; + std::string m_editSceneSnapshot = {}; + bool m_restored = false; +}; + std::pair SerializeComponent( const Component* component) { std::ostringstream payload = {}; @@ -1824,6 +1907,43 @@ public: return ResolvePrimaryScene(m_sceneManager); } + std::unique_ptr BeginPlaySession() override { + Scene* editScene = ResolvePrimaryScene(m_sceneManager); + if (editScene == nullptr) { + return nullptr; + } + + const std::string editSceneName = editScene->GetName().empty() + ? std::string("Untitled") + : editScene->GetName(); + const std::string editSceneSnapshot = editScene->SerializeToString(); + if (editSceneSnapshot.empty()) { + return nullptr; + } + + m_sceneManager.UnloadScene(editScene); + Scene* runtimeScene = CreateSceneFromSerializedSnapshot( + m_sceneManager, + m_resourceManager, + editSceneName + " Play", + editSceneSnapshot); + if (runtimeScene == nullptr) { + CreateSceneFromSerializedSnapshot( + m_sceneManager, + m_resourceManager, + editSceneName, + editSceneSnapshot); + return nullptr; + } + + return std::make_unique( + m_sceneManager, + m_resourceManager, + runtimeScene, + editSceneName, + editSceneSnapshot); + } + std::optional GetObjectSnapshot( std::string_view itemId) const override { const GameObject* gameObject = FindGameObject(itemId); diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index cbab0f1a..1338a289 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -66,6 +66,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES set(EDITOR_APP_CORE_TEST_SOURCES test_editor_host_command_bridge.cpp test_editor_project_runtime.cpp + test_editor_runtime_coordinator.cpp test_editor_scene_runtime_backend.cpp test_editor_shell_asset_validation.cpp test_project_browser_model.cpp @@ -154,6 +155,10 @@ if(TARGET XCEditorCore) MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") endif() + if(WIN32 AND XCENGINE_ENABLE_PHYSX) + xcengine_copy_physx_runtime_dlls(editor_app_core_tests) + endif() + gtest_discover_tests(editor_app_core_tests DISCOVERY_MODE POST_BUILD ) @@ -197,6 +202,10 @@ if(TARGET XCEditorCore) MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") endif() + if(WIN32 AND XCENGINE_ENABLE_PHYSX) + xcengine_copy_physx_runtime_dlls(editor_app_feature_tests) + endif() + gtest_discover_tests(editor_app_feature_tests DISCOVERY_MODE POST_BUILD ) diff --git a/tests/UI/Editor/unit/test_editor_runtime_coordinator.cpp b/tests/UI/Editor/unit/test_editor_runtime_coordinator.cpp new file mode 100644 index 00000000..5b907172 --- /dev/null +++ b/tests/UI/Editor/unit/test_editor_runtime_coordinator.cpp @@ -0,0 +1,248 @@ +#include + +#include "Project/EditorProjectRuntime.h" +#include "Runtime/EditorRuntimeCoordinator.h" +#include "Scene/EditorSceneBackend.h" +#include "Scene/EditorSceneRuntime.h" +#include "State/EditorSession.h" + +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::App { +namespace { + +using ::XCEngine::Components::Scene; + +class FakeEditorSceneBackend; + +class FakeEditorScenePlaySession final : public EditorScenePlaySession { +public: + explicit FakeEditorScenePlaySession(FakeEditorSceneBackend& backend) + : m_backend(backend) {} + + ~FakeEditorScenePlaySession() override; + + Scene* GetRuntimeScene() const override; + +private: + FakeEditorSceneBackend& m_backend; +}; + +class FakeEditorSceneBackend final : public EditorSceneBackend { +public: + FakeEditorSceneBackend() { + editScene = std::make_unique("Edit"); + activeScene = editScene.get(); + } + + EditorStartupSceneResult EnsureStartupScene( + const std::filesystem::path&) override { + return EditorStartupSceneResult{ + .ready = true, + .loadedFromDisk = true, + .scenePath = std::filesystem::path("D:/Project/Assets/Scenes/Main.xc"), + .sceneName = "Edit" + }; + } + + EditorSceneHierarchySnapshot BuildHierarchySnapshot() const override { + return {}; + } + + bool NewScene(std::string_view sceneName) override { + editScene = std::make_unique( + sceneName.empty() ? std::string("Untitled") : std::string(sceneName)); + activeScene = editScene.get(); + return true; + } + + bool OpenSceneAsset(const std::filesystem::path&) override { + activeScene = editScene.get(); + return true; + } + + bool SaveActiveScene(const std::filesystem::path&) override { + ++saveActiveSceneCallCount; + savedActiveSceneWasEditScene = activeScene == editScene.get(); + return true; + } + + Scene* GetActiveScene() const override { + return activeScene; + } + + std::unique_ptr BeginPlaySession() override { + ++beginPlaySessionCallCount; + if (failBeginPlaySession) { + return nullptr; + } + + runtimeScene = std::make_unique("Runtime"); + activeScene = runtimeScene.get(); + return std::make_unique(*this); + } + + std::optional GetObjectSnapshot( + std::string_view) const override { + return std::nullopt; + } + + bool AddComponent(std::string_view, std::string_view) override { + return false; + } + + bool RenameGameObject(std::string_view, std::string_view) override { + return false; + } + + bool DeleteGameObject(std::string_view) override { + return false; + } + + std::string DuplicateGameObject(std::string_view) override { + return {}; + } + + bool ReparentGameObject(std::string_view, std::string_view) override { + return false; + } + + bool MoveGameObjectBefore(std::string_view, std::string_view) override { + return false; + } + + bool MoveGameObjectAfter(std::string_view, std::string_view) override { + return false; + } + + bool MoveGameObjectToRoot(std::string_view) override { + return false; + } + + void EndPlaySession() { + ++endPlaySessionCallCount; + runtimeScene.reset(); + activeScene = editScene.get(); + } + + Scene* EditScene() const { + return editScene.get(); + } + + Scene* RuntimeScene() const { + return runtimeScene.get(); + } + + std::unique_ptr editScene = {}; + std::unique_ptr runtimeScene = {}; + Scene* activeScene = nullptr; + int beginPlaySessionCallCount = 0; + int endPlaySessionCallCount = 0; + int saveActiveSceneCallCount = 0; + bool savedActiveSceneWasEditScene = false; + bool failBeginPlaySession = false; +}; + +FakeEditorScenePlaySession::~FakeEditorScenePlaySession() { + m_backend.EndPlaySession(); +} + +Scene* FakeEditorScenePlaySession::GetRuntimeScene() const { + return m_backend.RuntimeScene(); +} + +struct RuntimeCoordinatorHarness { + RuntimeCoordinatorHarness() { + auto backend = std::make_unique(); + backendPtr = backend.get(); + sceneRuntime.SetBackend(std::move(backend)); + EXPECT_TRUE(sceneRuntime.Initialize("D:/Project")); + coordinator.Initialize( + session, + sceneRuntime, + projectRuntime, + EditorRuntimePaths{}); + } + + EditorSession session = {}; + EditorProjectRuntime projectRuntime = {}; + EditorSceneRuntime sceneRuntime = {}; + FakeEditorSceneBackend* backendPtr = nullptr; + EditorRuntimeCoordinator coordinator = {}; +}; + +TEST(EditorRuntimeCoordinatorTests, PlayModeRunsRuntimeSceneAndRestoresEditSceneOnStop) { + RuntimeCoordinatorHarness harness = {}; + ASSERT_NE(harness.backendPtr, nullptr); + ASSERT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->EditScene()); + + const UIEditorHostCommandDispatchResult playResult = + harness.coordinator.DispatchRunCommand("run.play"); + EXPECT_TRUE(playResult.commandExecuted); + EXPECT_EQ(harness.session.runtimeMode, EditorRuntimeMode::Play); + EXPECT_EQ(harness.backendPtr->beginPlaySessionCallCount, 1); + EXPECT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->RuntimeScene()); + EXPECT_NE(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->EditScene()); + + const UIEditorHostCommandDispatchResult stopResult = + harness.coordinator.DispatchRunCommand("run.stop"); + EXPECT_TRUE(stopResult.commandExecuted); + EXPECT_EQ(harness.session.runtimeMode, EditorRuntimeMode::Edit); + EXPECT_EQ(harness.backendPtr->endPlaySessionCallCount, 1); + EXPECT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->EditScene()); +} + +TEST(EditorRuntimeCoordinatorTests, StepFromEditCreatesPausedPlaySession) { + RuntimeCoordinatorHarness harness = {}; + ASSERT_NE(harness.backendPtr, nullptr); + + const UIEditorHostCommandDispatchResult stepResult = + harness.coordinator.DispatchRunCommand("run.step"); + EXPECT_TRUE(stepResult.commandExecuted); + EXPECT_EQ(harness.session.runtimeMode, EditorRuntimeMode::Paused); + EXPECT_EQ(harness.backendPtr->beginPlaySessionCallCount, 1); + EXPECT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->RuntimeScene()); + + const UIEditorHostCommandDispatchResult stopResult = + harness.coordinator.DispatchRunCommand("run.stop"); + EXPECT_TRUE(stopResult.commandExecuted); + EXPECT_EQ(harness.backendPtr->endPlaySessionCallCount, 1); + EXPECT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->EditScene()); +} + +TEST(EditorRuntimeCoordinatorTests, SaveAfterStoppingPlayTargetsRestoredEditScene) { + RuntimeCoordinatorHarness harness = {}; + ASSERT_NE(harness.backendPtr, nullptr); + + ASSERT_TRUE(harness.coordinator.DispatchRunCommand("run.play").commandExecuted); + ASSERT_TRUE(harness.coordinator.DispatchRunCommand("run.stop").commandExecuted); + + const UIEditorHostCommandDispatchResult saveResult = + harness.coordinator.DispatchFileCommand("file.save_scene"); + EXPECT_TRUE(saveResult.commandExecuted); + EXPECT_EQ(harness.backendPtr->saveActiveSceneCallCount, 1); + EXPECT_TRUE(harness.backendPtr->savedActiveSceneWasEditScene); +} + +TEST(EditorRuntimeCoordinatorTests, FailedPlaySessionLeavesEditorInEditMode) { + RuntimeCoordinatorHarness harness = {}; + ASSERT_NE(harness.backendPtr, nullptr); + harness.backendPtr->failBeginPlaySession = true; + + const UIEditorHostCommandDispatchResult playResult = + harness.coordinator.DispatchRunCommand("run.play"); + EXPECT_FALSE(playResult.commandExecuted); + EXPECT_EQ(harness.session.runtimeMode, EditorRuntimeMode::Edit); + EXPECT_EQ(harness.backendPtr->beginPlaySessionCallCount, 1); + EXPECT_EQ(harness.backendPtr->endPlaySessionCallCount, 0); + EXPECT_EQ(harness.sceneRuntime.GetActiveScene(), harness.backendPtr->EditScene()); +} + +} // namespace +} // namespace XCEngine::UI::Editor::App