feat: add play mode pause resume and step controls

This commit is contained in:
2026-04-02 19:56:07 +08:00
parent fb15d60be9
commit 1f29dfd611
11 changed files with 270 additions and 3 deletions

View File

@@ -20,6 +20,22 @@
- `Play / Paused` 下允许对 runtime scene 进行对象级编辑与 `Undo / Redo`
- runtime scene 的对象改动默认不再污染场景文档 dirty 状态
### 阶段 C 当前收口
- 已补全 `Pause / Resume / Step` 的完整请求与状态切换
- `Run` 菜单现在区分 `Play/Stop``Pause/Resume``Step`
- `Error Pause` 已接入正式 Pause 请求通道
- `Paused` 下维持 runtime world不回退到 editor scene
- `Step` 现在只在 `Paused` 下有效,并保持 `Paused` 状态不变
## 本轮验证
- 已重新执行 `cmake -S . -B build`
- 已通过 `cmake --build build --config Debug --target scene_tests`
- 已通过 `cmake --build build --config Debug --target editor_tests -- /m:1 /v:minimal`
- 已通过聚焦测试:
`ctest --test-dir build -C Debug --output-on-failure -j1 -R "RuntimeLoopTest|PlaySessionControllerTest|EditorActionRoutingTest.*PlayMode|EditorActionRoutingTest.*MainMenuRouterRequestsPlayPauseResumeAndStepEvents"`
## 当前语义
- `editor tick` 负责托管运行时会话
@@ -30,6 +46,6 @@
## 下一阶段建议
- 补全 `Pause / Resume / Step` 的完整状态机
- 明确 `Paused` 下的 `Undo / Redo / Gizmo / Inspector` 交互语义
- `Error Pause` 完整并入正式状态机
- 明确 `Paused` 下的 `Undo / Redo / Gizmo / Inspector` 更细粒度交互边界
- 将 GameView 输入正式接入 runtime input 通道
- 继续补 `Simulate` 与更完整的 Time 语义

View File

@@ -176,6 +176,27 @@ inline ActionBinding MakeTogglePlayModeAction(EditorRuntimeMode mode, bool enabl
Shortcut(ImGuiKey_F5));
}
inline ActionBinding MakeTogglePauseModeAction(EditorRuntimeMode mode, bool enabled = true) {
const bool paused = mode == EditorRuntimeMode::Paused;
return MakeAction(
paused ? "Resume" : "Pause",
"F6",
paused,
enabled,
false,
Shortcut(ImGuiKey_F6));
}
inline ActionBinding MakeStepPlayModeAction(bool enabled = true) {
return MakeAction(
"Step",
"F7",
false,
enabled,
false,
Shortcut(ImGuiKey_F7));
}
inline ActionBinding MakeNavigateBackAction(bool enabled) {
return MakeAction("<", "Alt+Left", false, enabled, false, Shortcut(ImGuiKey_LeftArrow, false, false, true));
}

View File

@@ -71,15 +71,42 @@ inline void RequestTogglePlayMode(IEditorContext& context) {
context.GetEventBus().Publish(PlayModeStopRequestedEvent{});
}
inline void RequestTogglePauseMode(IEditorContext& context) {
if (context.GetRuntimeMode() == EditorRuntimeMode::Play) {
context.GetEventBus().Publish(PlayModePauseRequestedEvent{});
return;
}
if (context.GetRuntimeMode() == EditorRuntimeMode::Paused) {
context.GetEventBus().Publish(PlayModeResumeRequestedEvent{});
}
}
inline void RequestStepPlayMode(IEditorContext& context) {
if (context.GetRuntimeMode() != EditorRuntimeMode::Paused) {
return;
}
context.GetEventBus().Publish(PlayModeStepRequestedEvent{});
}
inline void RequestAboutPopup(UI::DeferredPopupState& aboutPopup) {
aboutPopup.RequestOpen();
}
inline void HandleMainMenuShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) {
const bool canEditDocuments = IsDocumentEditingAllowed(context);
const bool canPause = context.GetRuntimeMode() == EditorRuntimeMode::Play || context.GetRuntimeMode() == EditorRuntimeMode::Paused;
const bool canStep = context.GetRuntimeMode() == EditorRuntimeMode::Paused;
HandleShortcut(MakeTogglePlayModeAction(context.GetRuntimeMode()), shortcutContext, [&]() {
RequestTogglePlayMode(context);
});
HandleShortcut(MakeTogglePauseModeAction(context.GetRuntimeMode(), canPause), shortcutContext, [&]() {
RequestTogglePauseMode(context);
});
HandleShortcut(MakeStepPlayModeAction(canStep), shortcutContext, [&]() {
RequestStepPlayMode(context);
});
HandleShortcut(MakeNewSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteNewScene(context); });
HandleShortcut(MakeOpenSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteOpenScene(context); });
HandleShortcut(MakeSaveSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteSaveScene(context); });
@@ -104,9 +131,17 @@ inline void DrawFileMenuActions(IEditorContext& context) {
}
inline void DrawRunMenuActions(IEditorContext& context) {
const bool canPause = context.GetRuntimeMode() == EditorRuntimeMode::Play || context.GetRuntimeMode() == EditorRuntimeMode::Paused;
const bool canStep = context.GetRuntimeMode() == EditorRuntimeMode::Paused;
DrawMenuAction(MakeTogglePlayModeAction(context.GetRuntimeMode()), [&]() {
RequestTogglePlayMode(context);
});
DrawMenuAction(MakeTogglePauseModeAction(context.GetRuntimeMode(), canPause), [&]() {
RequestTogglePauseMode(context);
});
DrawMenuAction(MakeStepPlayModeAction(canStep), [&]() {
RequestStepPlayMode(context);
});
}
inline void DrawViewMenuActions(IEditorContext& context) {

View File

@@ -58,6 +58,15 @@ struct PlayModePausedEvent {
struct PlayModePauseRequestedEvent {
};
struct PlayModeResumedEvent {
};
struct PlayModeResumeRequestedEvent {
};
struct PlayModeStepRequestedEvent {
};
struct EditorModeChangedEvent {
EditorRuntimeMode oldMode = EditorRuntimeMode::Edit;
EditorRuntimeMode newMode = EditorRuntimeMode::Edit;

View File

@@ -30,6 +30,20 @@ void PlaySessionController::Attach(IEditorContext& context) {
PausePlay(context);
});
}
if (m_playResumeRequestedHandlerId == 0) {
m_playResumeRequestedHandlerId = context.GetEventBus().Subscribe<PlayModeResumeRequestedEvent>(
[this, &context](const PlayModeResumeRequestedEvent&) {
ResumePlay(context);
});
}
if (m_playStepRequestedHandlerId == 0) {
m_playStepRequestedHandlerId = context.GetEventBus().Subscribe<PlayModeStepRequestedEvent>(
[this, &context](const PlayModeStepRequestedEvent&) {
StepPlay(context);
});
}
}
void PlaySessionController::Detach(IEditorContext& context) {
@@ -49,6 +63,16 @@ void PlaySessionController::Detach(IEditorContext& context) {
context.GetEventBus().Unsubscribe<PlayModePauseRequestedEvent>(m_playPauseRequestedHandlerId);
m_playPauseRequestedHandlerId = 0;
}
if (m_playResumeRequestedHandlerId != 0) {
context.GetEventBus().Unsubscribe<PlayModeResumeRequestedEvent>(m_playResumeRequestedHandlerId);
m_playResumeRequestedHandlerId = 0;
}
if (m_playStepRequestedHandlerId != 0) {
context.GetEventBus().Unsubscribe<PlayModeStepRequestedEvent>(m_playStepRequestedHandlerId);
m_playStepRequestedHandlerId = 0;
}
}
void PlaySessionController::Update(IEditorContext& context, float deltaTime) {
@@ -118,5 +142,25 @@ bool PlaySessionController::PausePlay(IEditorContext& context) {
return true;
}
bool PlaySessionController::ResumePlay(IEditorContext& context) {
if (context.GetRuntimeMode() != EditorRuntimeMode::Paused || !m_runtimeLoop.IsRunning()) {
return false;
}
m_runtimeLoop.Resume();
context.SetRuntimeMode(EditorRuntimeMode::Play);
context.GetEventBus().Publish(PlayModeResumedEvent{});
return true;
}
bool PlaySessionController::StepPlay(IEditorContext& context) {
if (context.GetRuntimeMode() != EditorRuntimeMode::Paused || !m_runtimeLoop.IsRunning()) {
return false;
}
m_runtimeLoop.StepFrame();
return true;
}
} // namespace Editor
} // namespace XCEngine

View File

@@ -22,11 +22,15 @@ public:
bool StartPlay(IEditorContext& context);
bool StopPlay(IEditorContext& context);
bool PausePlay(IEditorContext& context);
bool ResumePlay(IEditorContext& context);
bool StepPlay(IEditorContext& context);
private:
uint64_t m_playStartRequestedHandlerId = 0;
uint64_t m_playStopRequestedHandlerId = 0;
uint64_t m_playPauseRequestedHandlerId = 0;
uint64_t m_playResumeRequestedHandlerId = 0;
uint64_t m_playStepRequestedHandlerId = 0;
SceneSnapshot m_editorSnapshot = {};
XCEngine::Components::RuntimeLoop m_runtimeLoop;
};

View File

@@ -889,6 +889,12 @@ void ConsolePanel::OnAttach() {
HandlePlayModePaused();
});
}
if (!m_playModeResumedHandlerId) {
m_playModeResumedHandlerId = m_context->GetEventBus().Subscribe<PlayModeResumedEvent>(
[this](const PlayModeResumedEvent&) {
HandlePlayModeResumed();
});
}
}
void ConsolePanel::OnDetach() {
@@ -908,6 +914,10 @@ void ConsolePanel::OnDetach() {
m_context->GetEventBus().Unsubscribe<PlayModePausedEvent>(m_playModePausedHandlerId);
m_playModePausedHandlerId = 0;
}
if (m_playModeResumedHandlerId) {
m_context->GetEventBus().Unsubscribe<PlayModeResumedEvent>(m_playModeResumedHandlerId);
m_playModeResumedHandlerId = 0;
}
}
void ConsolePanel::HandlePlayModeStarted() {
@@ -941,6 +951,11 @@ void ConsolePanel::HandlePlayModePaused() {
m_playModePaused = true;
}
void ConsolePanel::HandlePlayModeResumed() {
m_playModeActive = true;
m_playModePaused = false;
}
void ConsolePanel::Render() {
UI::PanelWindowScope panel(m_name.c_str());
if (!panel.IsOpen()) {

View File

@@ -20,6 +20,7 @@ private:
void HandlePlayModeStarted();
void HandlePlayModeStopped();
void HandlePlayModePaused();
void HandlePlayModeResumed();
UI::ConsoleFilterState m_filterState;
char m_searchBuffer[256] = "";
@@ -29,6 +30,7 @@ private:
uint64_t m_playModeStartedHandlerId = 0;
uint64_t m_playModeStoppedHandlerId = 0;
uint64_t m_playModePausedHandlerId = 0;
uint64_t m_playModeResumedHandlerId = 0;
std::string m_selectedEntryKey;
float m_detailsHeight = 0.0f;
bool m_playModeActive = false;

View File

@@ -131,4 +131,25 @@ TEST_F(RuntimeLoopTest, PauseSkipsAutomaticTicksUntilStepFrameIsRequested) {
EXPECT_TRUE(loop.IsPaused());
}
TEST_F(RuntimeLoopTest, ResumeRestoresAutomaticTickProgressionAfterPause) {
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.updateCount, 0);
loop.Resume();
EXPECT_FALSE(loop.IsPaused());
loop.Tick(0.025f);
EXPECT_EQ(counters.startCount, 1);
EXPECT_EQ(counters.fixedUpdateCount, 1);
EXPECT_EQ(counters.updateCount, 1);
EXPECT_EQ(counters.lateUpdateCount, 1);
}
} // namespace

View File

@@ -284,6 +284,65 @@ TEST_F(EditorActionRoutingTest, MainMenuRouterRequestsExitResetAndAboutPopup) {
m_context.GetEventBus().Unsubscribe<DockLayoutResetRequestedEvent>(resetSubscription);
}
TEST_F(EditorActionRoutingTest, MainMenuRouterRequestsPlayPauseResumeAndStepEvents) {
int playStartRequestCount = 0;
int playStopRequestCount = 0;
int playPauseRequestCount = 0;
int playResumeRequestCount = 0;
int playStepRequestCount = 0;
const uint64_t playStartSubscription = m_context.GetEventBus().Subscribe<PlayModeStartRequestedEvent>(
[&](const PlayModeStartRequestedEvent&) {
++playStartRequestCount;
});
const uint64_t playStopSubscription = m_context.GetEventBus().Subscribe<PlayModeStopRequestedEvent>(
[&](const PlayModeStopRequestedEvent&) {
++playStopRequestCount;
});
const uint64_t playPauseSubscription = m_context.GetEventBus().Subscribe<PlayModePauseRequestedEvent>(
[&](const PlayModePauseRequestedEvent&) {
++playPauseRequestCount;
});
const uint64_t playResumeSubscription = m_context.GetEventBus().Subscribe<PlayModeResumeRequestedEvent>(
[&](const PlayModeResumeRequestedEvent&) {
++playResumeRequestCount;
});
const uint64_t playStepSubscription = m_context.GetEventBus().Subscribe<PlayModeStepRequestedEvent>(
[&](const PlayModeStepRequestedEvent&) {
++playStepRequestCount;
});
Actions::RequestTogglePlayMode(m_context);
EXPECT_EQ(playStartRequestCount, 1);
EXPECT_EQ(playStopRequestCount, 0);
m_context.SetRuntimeMode(EditorRuntimeMode::Play);
Actions::RequestTogglePauseMode(m_context);
EXPECT_EQ(playPauseRequestCount, 1);
EXPECT_EQ(playResumeRequestCount, 0);
Actions::RequestStepPlayMode(m_context);
EXPECT_EQ(playStepRequestCount, 0);
m_context.SetRuntimeMode(EditorRuntimeMode::Paused);
Actions::RequestTogglePauseMode(m_context);
EXPECT_EQ(playResumeRequestCount, 1);
Actions::RequestStepPlayMode(m_context);
EXPECT_EQ(playStepRequestCount, 1);
Actions::RequestTogglePlayMode(m_context);
EXPECT_EQ(playStopRequestCount, 1);
m_context.SetRuntimeMode(EditorRuntimeMode::Edit);
Actions::RequestStepPlayMode(m_context);
EXPECT_EQ(playStepRequestCount, 1);
m_context.GetEventBus().Unsubscribe<PlayModeStartRequestedEvent>(playStartSubscription);
m_context.GetEventBus().Unsubscribe<PlayModeStopRequestedEvent>(playStopSubscription);
m_context.GetEventBus().Unsubscribe<PlayModePauseRequestedEvent>(playPauseSubscription);
m_context.GetEventBus().Unsubscribe<PlayModeResumeRequestedEvent>(playResumeSubscription);
m_context.GetEventBus().Unsubscribe<PlayModeStepRequestedEvent>(playStepSubscription);
}
TEST_F(EditorActionRoutingTest, PlayModeAllowsRuntimeSceneUndoRedoButKeepsSceneDocumentCommandsBlocked) {
const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "PlayModeRuntimeEditing.xc";
ASSERT_TRUE(m_context.GetSceneManager().SaveSceneAs(savedScenePath.string()));

View File

@@ -79,5 +79,46 @@ TEST_F(PlaySessionControllerTest, StartAndStopRequestsRouteThroughEventBus) {
m_controller.Detach(m_context);
}
TEST_F(PlaySessionControllerTest, PauseResumeAndStepRequestsDrivePlayStateMachine) {
auto* editorEntity = m_context.GetSceneManager().CreateEntity("Persistent");
ASSERT_NE(editorEntity, nullptr);
int pausedCount = 0;
int resumedCount = 0;
const uint64_t pausedSubscription = m_context.GetEventBus().Subscribe<PlayModePausedEvent>(
[&](const PlayModePausedEvent&) {
++pausedCount;
});
const uint64_t resumedSubscription = m_context.GetEventBus().Subscribe<PlayModeResumedEvent>(
[&](const PlayModeResumedEvent&) {
++resumedCount;
});
m_controller.Attach(m_context);
m_context.GetEventBus().Publish(PlayModeStartRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play);
m_context.GetEventBus().Publish(PlayModePauseRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Paused);
EXPECT_EQ(pausedCount, 1);
m_context.GetEventBus().Publish(PlayModeStepRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Paused);
EXPECT_EQ(pausedCount, 1);
EXPECT_EQ(resumedCount, 0);
m_context.GetEventBus().Publish(PlayModeResumeRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play);
EXPECT_EQ(resumedCount, 1);
m_context.GetEventBus().Publish(PlayModeStopRequestedEvent{});
EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Edit);
m_controller.Detach(m_context);
m_context.GetEventBus().Unsubscribe<PlayModePausedEvent>(pausedSubscription);
m_context.GetEventBus().Unsubscribe<PlayModeResumedEvent>(resumedSubscription);
}
} // namespace
} // namespace XCEngine::Editor