feat: add runtime play tick and play-mode scene editing semantics

This commit is contained in:
2026-04-02 19:37:35 +08:00
parent e30f5d5ffa
commit fb15d60be9
28 changed files with 2016 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ project(XCEngine_SceneTests)
set(SCENE_TEST_SOURCES
test_scene.cpp
test_scene_runtime.cpp
test_runtime_loop.cpp
test_scene_manager.cpp
)

View File

@@ -0,0 +1,134 @@
#include <gtest/gtest.h>
#include <XCEngine/Components/Component.h>
#include <XCEngine/Components/GameObject.h>
#include <XCEngine/Scene/RuntimeLoop.h>
#include <XCEngine/Scene/Scene.h>
#include <memory>
#include <string>
using namespace XCEngine::Components;
namespace {
struct RuntimeLoopCounters {
int startCount = 0;
int fixedUpdateCount = 0;
int updateCount = 0;
int lateUpdateCount = 0;
};
class RuntimeLoopObserverComponent : public Component {
public:
explicit RuntimeLoopObserverComponent(RuntimeLoopCounters* counters)
: m_counters(counters) {
}
std::string GetName() const override {
return "RuntimeLoopObserver";
}
void Start() override {
if (m_counters) {
++m_counters->startCount;
}
}
void FixedUpdate() override {
if (m_counters) {
++m_counters->fixedUpdateCount;
}
}
void Update(float deltaTime) override {
(void)deltaTime;
if (m_counters) {
++m_counters->updateCount;
}
}
void LateUpdate(float deltaTime) override {
(void)deltaTime;
if (m_counters) {
++m_counters->lateUpdateCount;
}
}
private:
RuntimeLoopCounters* m_counters = nullptr;
};
class RuntimeLoopTest : public ::testing::Test {
protected:
Scene* CreateScene(const std::string& name = "RuntimeLoopScene") {
m_scene = std::make_unique<Scene>(name);
return m_scene.get();
}
RuntimeLoopCounters counters;
std::unique_ptr<Scene> m_scene;
};
TEST_F(RuntimeLoopTest, AccumulatesFixedUpdatesAcrossFramesAndRunsVariableUpdatesEveryTick) {
RuntimeLoop loop({0.02f, 0.1f, 4});
Scene* scene = CreateScene();
GameObject* host = scene->CreateGameObject("Host");
host->AddComponent<RuntimeLoopObserverComponent>(&counters);
loop.Start(scene);
loop.Tick(0.01f);
EXPECT_EQ(counters.fixedUpdateCount, 0);
EXPECT_EQ(counters.startCount, 1);
EXPECT_EQ(counters.updateCount, 1);
EXPECT_EQ(counters.lateUpdateCount, 1);
loop.Tick(0.01f);
EXPECT_EQ(counters.fixedUpdateCount, 1);
EXPECT_EQ(counters.startCount, 1);
EXPECT_EQ(counters.updateCount, 2);
EXPECT_EQ(counters.lateUpdateCount, 2);
}
TEST_F(RuntimeLoopTest, ClampAndFixedStepLimitPreventExcessiveCatchUp) {
RuntimeLoop loop({0.02f, 0.05f, 2});
Scene* scene = CreateScene();
GameObject* host = scene->CreateGameObject("Host");
host->AddComponent<RuntimeLoopObserverComponent>(&counters);
loop.Start(scene);
loop.Tick(1.0f);
EXPECT_EQ(counters.fixedUpdateCount, 2);
EXPECT_EQ(counters.startCount, 1);
EXPECT_EQ(counters.updateCount, 1);
EXPECT_EQ(counters.lateUpdateCount, 1);
EXPECT_NEAR(loop.GetFixedAccumulator(), 0.01f, 1e-4f);
}
TEST_F(RuntimeLoopTest, PauseSkipsAutomaticTicksUntilStepFrameIsRequested) {
RuntimeLoop loop({0.02f, 0.1f, 4});
Scene* scene = CreateScene();
GameObject* host = scene->CreateGameObject("Host");
host->AddComponent<RuntimeLoopObserverComponent>(&counters);
loop.Start(scene);
loop.Pause();
loop.Tick(0.025f);
EXPECT_EQ(counters.fixedUpdateCount, 0);
EXPECT_EQ(counters.startCount, 0);
EXPECT_EQ(counters.updateCount, 0);
EXPECT_EQ(counters.lateUpdateCount, 0);
loop.StepFrame();
loop.Tick(0.025f);
EXPECT_EQ(counters.fixedUpdateCount, 1);
EXPECT_EQ(counters.startCount, 1);
EXPECT_EQ(counters.updateCount, 1);
EXPECT_EQ(counters.lateUpdateCount, 1);
EXPECT_TRUE(loop.IsPaused());
}
} // namespace

View File

@@ -4,6 +4,7 @@ project(XCEngine_EditorTests)
set(EDITOR_TEST_SOURCES
test_action_routing.cpp
test_play_session_controller.cpp
test_scene_viewport_camera_controller.cpp
test_scene_viewport_move_gizmo.cpp
test_scene_viewport_rotate_gizmo.cpp
@@ -16,6 +17,7 @@ set(EDITOR_TEST_SOURCES
test_viewport_render_flow_utils.cpp
test_builtin_icon_layout_utils.cpp
${CMAKE_SOURCE_DIR}/editor/src/Core/UndoManager.cpp
${CMAKE_SOURCE_DIR}/editor/src/Core/PlaySessionController.cpp
${CMAKE_SOURCE_DIR}/editor/src/Managers/SceneManager.cpp
${CMAKE_SOURCE_DIR}/editor/src/Managers/ProjectManager.cpp
${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportPicker.cpp
@@ -30,6 +32,7 @@ if(MSVC)
set_target_properties(editor_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
target_compile_options(editor_tests PRIVATE /FS)
endif()
target_link_libraries(editor_tests PRIVATE

View File

@@ -7,6 +7,7 @@
#include "Commands/EntityCommands.h"
#include "Commands/SceneCommands.h"
#include "Core/EditorContext.h"
#include "Core/PlaySessionController.h"
#include <XCEngine/Core/Math/Quaternion.h>
#include <XCEngine/Core/Math/Vector3.h>
@@ -283,6 +284,45 @@ TEST_F(EditorActionRoutingTest, MainMenuRouterRequestsExitResetAndAboutPopup) {
m_context.GetEventBus().Unsubscribe<DockLayoutResetRequestedEvent>(resetSubscription);
}
TEST_F(EditorActionRoutingTest, PlayModeAllowsRuntimeSceneUndoRedoButKeepsSceneDocumentCommandsBlocked) {
const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "PlayModeRuntimeEditing.xc";
ASSERT_TRUE(m_context.GetSceneManager().SaveSceneAs(savedScenePath.string()));
ASSERT_FALSE(m_context.GetSceneManager().IsSceneDirty());
PlaySessionController controller;
ASSERT_TRUE(controller.StartPlay(m_context));
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play);
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDocumentDirtyTrackingEnabled());
EXPECT_FALSE(Commands::NewScene(m_context, "Blocked During Play"));
EXPECT_FALSE(Commands::SaveCurrentScene(m_context));
const size_t entityCountBeforeCreate = CountHierarchyEntities(m_context.GetSceneManager());
auto* runtimeEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Runtime Entity", "RuntimeOnly");
ASSERT_NE(runtimeEntity, nullptr);
const uint64_t runtimeEntityId = runtimeEntity->GetID();
EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate + 1);
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty());
const Actions::ActionBinding undoAction = Actions::MakeUndoAction(m_context);
EXPECT_TRUE(undoAction.enabled);
Actions::ExecuteUndo(m_context);
EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate);
EXPECT_EQ(m_context.GetSceneManager().GetEntity(runtimeEntityId), nullptr);
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty());
const Actions::ActionBinding redoAction = Actions::MakeRedoAction(m_context);
EXPECT_TRUE(redoAction.enabled);
Actions::ExecuteRedo(m_context);
EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate + 1);
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty());
ASSERT_TRUE(controller.StopPlay(m_context));
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Edit);
EXPECT_TRUE(m_context.GetSceneManager().IsSceneDocumentDirtyTrackingEnabled());
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty());
}
TEST_F(EditorActionRoutingTest, HierarchyRouterRenameHelpersPublishAndCommit) {
auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "BeforeRename");
ASSERT_NE(entity, nullptr);

View File

@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include "Core/EditorContext.h"
#include "Core/EditorEvents.h"
#include "Core/PlaySessionController.h"
#include <XCEngine/Core/Math/Vector3.h>
namespace XCEngine::Editor {
namespace {
class PlaySessionControllerTest : public ::testing::Test {
protected:
void SetUp() override {
m_context.GetSceneManager().NewScene("Play Session Scene");
}
EditorContext m_context;
PlaySessionController m_controller;
};
TEST_F(PlaySessionControllerTest, StartPlayClonesCurrentSceneAndStopRestoresEditorScene) {
auto* editorEntity = m_context.GetSceneManager().CreateEntity("Persistent");
ASSERT_NE(editorEntity, nullptr);
const uint64_t editorEntityId = editorEntity->GetID();
editorEntity->GetTransform()->SetLocalPosition(Math::Vector3(1.0f, 2.0f, 3.0f));
int startedCount = 0;
int stoppedCount = 0;
const uint64_t startedSubscription = m_context.GetEventBus().Subscribe<PlayModeStartedEvent>(
[&](const PlayModeStartedEvent&) {
++startedCount;
});
const uint64_t stoppedSubscription = m_context.GetEventBus().Subscribe<PlayModeStoppedEvent>(
[&](const PlayModeStoppedEvent&) {
++stoppedCount;
});
ASSERT_TRUE(m_controller.StartPlay(m_context));
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play);
EXPECT_EQ(startedCount, 1);
auto* runtimeEntity = m_context.GetSceneManager().GetEntity(editorEntityId);
ASSERT_NE(runtimeEntity, nullptr);
runtimeEntity->GetTransform()->SetLocalPosition(Math::Vector3(8.0f, 9.0f, 10.0f));
auto* runtimeOnlyEntity = m_context.GetSceneManager().CreateEntity("RuntimeOnly");
ASSERT_NE(runtimeOnlyEntity, nullptr);
const uint64_t runtimeOnlyId = runtimeOnlyEntity->GetID();
ASSERT_TRUE(m_controller.StopPlay(m_context));
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Edit);
EXPECT_EQ(stoppedCount, 1);
auto* restoredEntity = m_context.GetSceneManager().GetEntity(editorEntityId);
ASSERT_NE(restoredEntity, nullptr);
EXPECT_EQ(m_context.GetSceneManager().GetEntity(runtimeOnlyId), nullptr);
const Math::Vector3 restoredPosition = restoredEntity->GetTransform()->GetLocalPosition();
EXPECT_NEAR(restoredPosition.x, 1.0f, 1e-4f);
EXPECT_NEAR(restoredPosition.y, 2.0f, 1e-4f);
EXPECT_NEAR(restoredPosition.z, 3.0f, 1e-4f);
m_context.GetEventBus().Unsubscribe<PlayModeStartedEvent>(startedSubscription);
m_context.GetEventBus().Unsubscribe<PlayModeStoppedEvent>(stoppedSubscription);
}
TEST_F(PlaySessionControllerTest, StartAndStopRequestsRouteThroughEventBus) {
auto* editorEntity = m_context.GetSceneManager().CreateEntity("Persistent");
ASSERT_NE(editorEntity, nullptr);
m_controller.Attach(m_context);
m_context.GetEventBus().Publish(PlayModeStartRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play);
m_context.GetEventBus().Publish(PlayModeStopRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Edit);
m_controller.Detach(m_context);
}
} // namespace
} // namespace XCEngine::Editor