From 2577809ee4a81a95a2837599458d1283a419f025 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sun, 4 Oct 2026 02:22:49 +0800 Subject: [PATCH] feat: Play-In-Editor (PIE) with in-memory scene snapshot Editor toolbar gains Play/Stop (Ctrl+P) with UE semantics: - Play: serialize current scene to an in-memory YAML snapshot via new SceneSerializer::SerializeToString, switch display to kDisplayDirect (primary camera entity renders), unfreeze time (timeScale 0 -> 1) - Stop: DeserializeFromString atomically rebuilds the pre-play scene, back to kDisplayViewport (editor camera), refreeze time; selection cleared and gizmo drag reset since all entity handles dangle - SceneSerializer refactored: SerializeDoc/ParseRoot shared by file and memory variants; file Save/Load behavior unchanged - Running state disables gizmo + editor hotkeys in the viewport (input goes to game scripts), draws yellow PLAYING hint and a no-camera warning; File->Save/Load disabled while playing (UE behavior) Physics bodies/constraints/characters and script instances are reconciled automatically by the existing per-frame sync on restore. --- App/editor/Editor.cpp | 97 ++++++++++ App/editor/Editor.h | 15 ++ App/editor/EditorContext.h | 4 + App/editor/ViewportPanel.cpp | 39 +++- .../Engine/Serialization/SceneSerializer.h | 17 ++ Engine/src/Serialization/SceneSerializer.cpp | 178 ++++++++++++------ 6 files changed, 291 insertions(+), 59 deletions(-) diff --git a/App/editor/Editor.cpp b/App/editor/Editor.cpp index f1ad388..6be2b17 100644 --- a/App/editor/Editor.cpp +++ b/App/editor/Editor.cpp @@ -17,6 +17,7 @@ #include "StatsPanel.h" #include +#include #include #include @@ -36,6 +37,8 @@ Editor::~Editor() = default; void Editor::Init(const EditorContext& ctx) { m_Ctx = ctx; + // PIE 运行态指针:指向本对象成员(ctx 拷贝后补接,面板只读) + m_Ctx.playState = &m_Playing; m_Hierarchy = std::make_unique(); m_Inspector = std::make_unique(); @@ -44,6 +47,60 @@ void Editor::Init(const EditorContext& ctx) m_AssetBrowser = std::make_unique(); } +// ---- PIE(Play-In-Editor)---- + +// Play:当前场景 → 内存 YAML 快照 → 切游戏渲染(primary 相机实体) +// → 解冻时间(物理/脚本/动画开始跑)。快照失败不进 Play(现场不动) +void Editor::DoPlay() +{ + if (!m_Ctx.scene || !m_Ctx.assets || !m_Ctx.renderer) return; + + m_PlaySnapshot = Serialization::SceneSerializer::SerializeToString( + *m_Ctx.scene, *m_Ctx.assets, "PlaySnapshot"); + if (m_PlaySnapshot.empty()) + { + ENGINE_ERROR("PIE: snapshot failed, play aborted"); + return; + } + + m_Playing = true; + m_Ctx.renderer->SetDisplayMode(kDisplayDirect); + Time::SetTimeScale(1.0f); + ENGINE_INFO("PIE: playing (scene snapshot {} bytes)", + m_PlaySnapshot.size()); +} + +// Stop:快照原子换血恢复 → 回编辑器渲染 → 冻结时间。 +// 换血后所有 Entity 句柄悬空:清选中(描边同步清)、Gizmo 拖拽态 +// 复位。物理 body/约束/角色由对账系统随实体重建/清理,无需干预。 +// 恢复失败(快照损坏,理论罕见)也必须回编辑态,现场留给用户 +// File→Load 或退出前 Save +void Editor::DoStop() +{ + if (!m_Ctx.scene || !m_Ctx.assets || !m_Ctx.renderer) return; + + std::string envPath; + if (!Serialization::SceneSerializer::DeserializeFromString( + *m_Ctx.scene, *m_Ctx.assets, m_PlaySnapshot, envPath)) + { + ENGINE_ERROR("PIE: snapshot restore failed (scene is now the " + "played-out state)"); + } + m_PlaySnapshot.clear(); + + m_Playing = false; + m_Ctx.renderer->SetDisplayMode(kDisplayViewport); + Time::SetTimeScale(0.0f); + SelectEntity(entt::null); + m_Gizmo.dragging = false; + ENGINE_INFO("PIE: stopped, editor scene restored"); +} + +void Editor::TogglePlay() +{ + m_Playing ? DoStop() : DoPlay(); +} + void Editor::Draw() { // ImGuizmo 每帧首调(官方约定):内部会清 mbOverGizmoHotspot 等 @@ -133,8 +190,47 @@ void Editor::DrawMenuBar() return; } + // ---- PIE 工具栏(UE 主工具栏语义):Play/Stop 切换按钮 + + // Ctrl+P 全局热键。运行态按钮变 Stop(黄底提示在视口内)---- + { + const ImGuiIO& io = ImGui::GetIO(); + // Ctrl+P:文本输入中(改名/路径框)不触发,防打字误切 PIE + if (!m_Playing && io.KeyCtrl && !io.WantTextInput && + ImGui::IsKeyPressed(ImGuiKey_P, false)) + { + TogglePlay(); + } + + if (m_Playing) + { + ImGui::PushStyleColor(ImGuiCol_Button, + ImVec4(0.75f, 0.45f, 0.10f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, + ImVec4(0.85f, 0.55f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, + ImVec4(0.90f, 0.62f, 0.20f, 1.0f)); + if (ImGui::SmallButton("Stop (Ctrl+P)")) + { + TogglePlay(); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.72f, 0.25f, 1.0f), + "PLAYING - editor input disabled"); + } + else if (ImGui::SmallButton("Play (Ctrl+P)")) + { + TogglePlay(); + } + ImGui::SameLine(); + ImGui::Separator(); + } + if (ImGui::BeginMenu("File")) { + // 运行态禁存取:Save 会把"玩出来的状态"当编辑结果落盘, + // Load 会撕掉快照恢复链(UE 运行态同样禁) + ImGui::BeginDisabled(m_Playing); if (ImGui::MenuItem("Save Scene...", nullptr)) { m_ShowSaveModal = true; @@ -143,6 +239,7 @@ void Editor::DrawMenuBar() { m_ShowLoadModal = true; } + ImGui::EndDisabled(); ImGui::Separator(); if (ImGui::MenuItem("Quit", "Alt+F4")) { diff --git a/App/editor/Editor.h b/App/editor/Editor.h index 2009c89..0353e2f 100644 --- a/App/editor/Editor.h +++ b/App/editor/Editor.h @@ -55,6 +55,17 @@ public: // 请求退出(弹确认框;窗口关闭事件走这里) void RequestQuit() { m_ShowQuitConfirm = true; } + // ---- PIE(Play-In-Editor,UE 工具栏语义)---- + bool IsPlaying() const { return m_Playing; } + // Play:内存快照当前场景 → 切游戏渲染(primary 相机)→ 解冻时间; + // Stop:快照原子换血恢复 → 回编辑器渲染 → 冻结时间。 + // 编辑器态默认 timeScale=0(物理冻结 = UE 语义),Play 解冻 + void TogglePlay(); + +private: + void DoPlay(); // TogglePlay 的 Play 半程(快照 + 切渲染 + 解冻) + void DoStop(); // TogglePlay 的 Stop 半程(恢复 + 回编辑器 + 冻结) + // ---- 选中状态 ---- // 同步描边:Renderer 落帧后 SelectionMask/Outline 只画选中实体 //(bits = 实体位模式,0 = 清选中) @@ -109,6 +120,10 @@ private: bool m_ShowSaveModal = false; bool m_ShowLoadModal = false; char m_ScenePathBuf[256] = "scenes/sandbox.scene"; + + // PIE 状态:Play 时场景 YAML 内存快照,Stop 时原子换血恢复 + bool m_Playing = false; + std::string m_PlaySnapshot; }; #endif //HELLOVULKAN_EDITOR_H diff --git a/App/editor/EditorContext.h b/App/editor/EditorContext.h index 41c0273..e0cf07c 100644 --- a/App/editor/EditorContext.h +++ b/App/editor/EditorContext.h @@ -42,6 +42,10 @@ struct EditorContext { // 无关;面板经此做 F 焦点/Gizmo 视图/对齐跳转) Engine::EditorCamera *editorCamera = nullptr; + // PIE 运行态(Play-In-Editor):Editor 持有,只读指针。 + // 面板门控用——运行态禁 Gizmo/编辑器热键,渲染源已是 primary 相机 + const bool *playState = nullptr; + std::function getEnvIndex; // 当前环境表下标 std::function setEnvIndex; }; diff --git a/App/editor/ViewportPanel.cpp b/App/editor/ViewportPanel.cpp index 76aab2e..72478f4 100644 --- a/App/editor/ViewportPanel.cpp +++ b/App/editor/ViewportPanel.cpp @@ -152,7 +152,10 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, selected != entt::null ? reg.try_get(selected) : nullptr; - if (out.focused && !ImGui::GetIO().WantTextInput && + // PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器 + // 热键全部让位(UE Play 语义:Scene view 编辑操作冻结) + const bool playing = ctx.playState && *ctx.playState; + if (!playing && out.focused && !ImGui::GetIO().WantTextInput && !ImGui::IsMouseDown(ImGuiMouseButton_Right)) { if (ImGui::IsKeyPressed(ImGuiKey_W)) gizmo.op = ImGuizmo::TRANSLATE; if (ImGui::IsKeyPressed(ImGuiKey_E)) gizmo.op = ImGuizmo::ROTATE; @@ -169,7 +172,8 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, // ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)---- // 选中实体 + 有 Transform 才出杆;矩阵分解直接写回 // TransformComponent(与 Inspector 同源,下一帧数值自动跟随) - if (tr != nullptr) { + // PIE 运行态不出杆(编辑操作冻结) + if (tr != nullptr && !playing) { // 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏 // 形态 = primary 相机实体;与 SceneRenderer::BuildFrame // 同一分派),参数与渲染侧一致(aspect = 渲染分辨率、 @@ -277,14 +281,23 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, } // Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行) + // PIE 运行态 = 黄色 PLAYING 提示替代编辑状态 { char tip[64]; - std::snprintf(tip, sizeof(tip), "Space: %s [T] Snap: Ctrl", - gizmo.localSpace ? "Local" : "World"); + if (playing) { + std::snprintf(tip, sizeof(tip), + "PLAYING - Ctrl+P to stop"); + } else { + std::snprintf(tip, sizeof(tip), + "Space: %s [T] Snap: Ctrl", + gizmo.localSpace ? "Local" : "World"); + } const ImVec2 imgMin = ImGui::GetItemRectMin(); ImGui::GetWindowDrawList()->AddText( ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f), - IM_COL32(255, 255, 255, 150), tip); + playing ? IM_COL32(255, 184, 46, 220) + : IM_COL32(255, 255, 255, 150), + tip); } // Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻 @@ -292,6 +305,22 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, // 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方 // 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率 // 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?) + if (playing) { + // 运行态无 primary 相机实体 = 游戏画面黑屏(BuildFrame + // 跳帧),提示补相机(Hierarchy 右键或 Add Component) + ECS::TransformComponent *camT = nullptr; + if (ViewCamera(ctx, camT) == nullptr) { + const ImVec2 imgMin = ImGui::GetItemRectMin(); + ImGui::GetWindowDrawList()->AddText( + ImVec2(imgMin.x + 8.0f, + imgMin.y + size.y * 0.5f), + IM_COL32(255, 120, 80, 230), + "No primary camera entity - add a Camera " + "component and check 'Primary'"); + } + } + + // Dynamic 刚体提示(drawlist 叠加,理由同上) if (selected != entt::null) { if (const auto *rb = reg.try_get(selected); rb != nullptr && diff --git a/Engine/include/Engine/Serialization/SceneSerializer.h b/Engine/include/Engine/Serialization/SceneSerializer.h index 53d08d9..fb8288f 100644 --- a/Engine/include/Engine/Serialization/SceneSerializer.h +++ b/Engine/include/Engine/Serialization/SceneSerializer.h @@ -55,6 +55,23 @@ namespace Engine::Serialization { Assets::AssetManager& assets, const std::string& inPath, std::string& outEnvironmentPath); + + // ---- 内存快照(同一套实体读写,不同载体)---- + // 消费者:编辑器 Play-In-Editor 快照恢复、Prefab、运行时动态 + // 拼装场景。documentName 仅写入文档头 "Scene" 字段(注释性), + // 快照可传实体名或留空;environmentPath 语义同文件版。 + static std::string SerializeToString( + const ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& documentName = {}, + const std::string& environmentPath = {}); + + // 失败(YAML 异常/版本过高)返回 false 且不动现场。 + // 实体悬空语义同文件版 Deserialize。 + static bool DeserializeFromString(ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& yamlText, + std::string& outEnvironmentPath); }; } // namespace Engine::Serialization diff --git a/Engine/src/Serialization/SceneSerializer.cpp b/Engine/src/Serialization/SceneSerializer.cpp index 1e40c84..002eb24 100644 --- a/Engine/src/Serialization/SceneSerializer.cpp +++ b/Engine/src/Serialization/SceneSerializer.cpp @@ -384,47 +384,65 @@ namespace Engine::Serialization { // ---- 保存 ---- + // Emitter 构建段(文件版与内存版共用):documentName 空则不写 + // 文档头 "Scene" 字段(快照无文件名,该字段是注释性的) + static std::string SerializeDoc(const ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& documentName, + const std::string& environmentPath) + { + // float32 无损往返需 9 位有效数字(std::numeric_limits + // ::max_digits10) + YAML::Emitter out; + out.SetFloatPrecision(9); + + out << YAML::BeginMap; + if (!documentName.empty()) { + out << YAML::Key << "Scene" << YAML::Value << documentName; + } + out << YAML::Key << "Version" << YAML::Value << kSceneVersion; + if (!environmentPath.empty()) { + out << YAML::Key << "Environment" << YAML::Value + << environmentPath; + } + + const auto& s = scene.GetSettings(); + out << YAML::Key << "Settings" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "ClearColor" << YAML::Value; + EmitVec4(out, s.ClearColor); + out << YAML::Key << "AmbientColor" << YAML::Value; + EmitVec4(out, s.AmbientColor); + out << YAML::Key << "DrawSkybox" << YAML::Value << s.DrawSkybox; + out << YAML::EndMap; + + out << YAML::Key << "Entities" << YAML::Value << YAML::BeginSeq; + const ECS::Registry& reg = scene.GetRegistry(); + reg.view().each( + [&](entt::entity e, const ECS::IdComponent&) { + EmitEntity(out, assets, reg, e); + }); + out << YAML::EndSeq; + out << YAML::EndMap; + + if (!out.good()) { + ENGINE_ERROR("SceneSerializer: emit failed: {}", out.GetLastError()); + return {}; + } + return {out.c_str(), out.size()}; + } + bool SceneSerializer::Serialize(const ECS::Scene& scene, Assets::AssetManager& assets, const std::string& outPath, const std::string& environmentPath) { try { - // float32 无损往返需 9 位有效数字(std::numeric_limits - // ::max_digits10) - YAML::Emitter out; - out.SetFloatPrecision(9); - - out << YAML::BeginMap; - out << YAML::Key << "Scene" << YAML::Value - << std::filesystem::path(outPath).stem().string(); - out << YAML::Key << "Version" << YAML::Value << kSceneVersion; - if (!environmentPath.empty()) { - out << YAML::Key << "Environment" << YAML::Value - << environmentPath; - } - - const auto& s = scene.GetSettings(); - out << YAML::Key << "Settings" << YAML::Value << YAML::BeginMap; - out << YAML::Key << "ClearColor" << YAML::Value; - EmitVec4(out, s.ClearColor); - out << YAML::Key << "AmbientColor" << YAML::Value; - EmitVec4(out, s.AmbientColor); - out << YAML::Key << "DrawSkybox" << YAML::Value << s.DrawSkybox; - out << YAML::EndMap; - - out << YAML::Key << "Entities" << YAML::Value << YAML::BeginSeq; - const ECS::Registry& reg = scene.GetRegistry(); - reg.view().each( - [&](entt::entity e, const ECS::IdComponent&) { - EmitEntity(out, assets, reg, e); - }); - out << YAML::EndSeq; - out << YAML::EndMap; - - if (!out.good()) { - ENGINE_ERROR("SceneSerializer: emit failed: {}", out.GetLastError()); - return false; + const std::string doc = SerializeDoc( + scene, assets, + std::filesystem::path(outPath).stem().string(), + environmentPath); + if (doc.empty()) { + return false; // emit 失败,日志已在 SerializeDoc 打过 } std::ofstream file(outPath, std::ios::trunc); @@ -433,7 +451,7 @@ namespace Engine::Serialization { outPath); return false; } - file << out.c_str(); + file << doc; return true; } catch (const YAML::Exception& e) { @@ -446,35 +464,42 @@ namespace Engine::Serialization { } } - // ---- 加载 ---- - - bool SceneSerializer::Deserialize(ECS::Scene& scene, - Assets::AssetManager& assets, - const std::string& inPath, - std::string& outEnvironmentPath) + std::string SceneSerializer::SerializeToString( + const ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& documentName, + const std::string& environmentPath) { - outEnvironmentPath.clear(); - - YAML::Node root; try { - root = YAML::LoadFile(inPath); + return SerializeDoc(scene, assets, documentName, environmentPath); } catch (const YAML::Exception& e) { - ENGINE_ERROR("SceneSerializer: cannot parse '{}' ({})", inPath, - e.what()); - return false; + ENGINE_ERROR("SceneSerializer: snapshot failed ({})", e.what()); + return {}; } catch (const std::exception& e) { - ENGINE_ERROR("SceneSerializer: cannot read '{}' ({})", inPath, - e.what()); - return false; + ENGINE_ERROR("SceneSerializer: snapshot failed ({})", e.what()); + return {}; } + } + + // ---- 加载 ---- + + // 解析段(文件版与内存快照共用):root → 原子换血重建。 + // sourceName 仅用于日志(文件路径或 "snapshot") + static bool ParseRoot(ECS::Scene& scene, + Assets::AssetManager& assets, + const YAML::Node& root, + const std::string& sourceName, + std::string& outEnvironmentPath) + { + outEnvironmentPath.clear(); // Version 闸:高版本拒载(结构未知),缺失按 0 宽容旧档 if (const int version = root["Version"].as(0); version > kSceneVersion) { ENGINE_ERROR("SceneSerializer: '{}' version {} > supported {}", - inPath, version, kSceneVersion); + sourceName, version, kSceneVersion); return false; } @@ -714,8 +739,53 @@ namespace Engine::Serialization { scene.UpdateTransforms(); ENGINE_INFO("SceneSerializer: loaded {} entities from '{}'", - parsed.size(), inPath); + parsed.size(), sourceName); return true; } + bool SceneSerializer::Deserialize(ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& inPath, + std::string& outEnvironmentPath) + { + YAML::Node root; + try { + root = YAML::LoadFile(inPath); + } + catch (const YAML::Exception& e) { + ENGINE_ERROR("SceneSerializer: cannot parse '{}' ({})", inPath, + e.what()); + return false; + } + catch (const std::exception& e) { + ENGINE_ERROR("SceneSerializer: cannot read '{}' ({})", inPath, + e.what()); + return false; + } + return ParseRoot(scene, assets, root, inPath, outEnvironmentPath); + } + + bool SceneSerializer::DeserializeFromString( + ECS::Scene& scene, + Assets::AssetManager& assets, + const std::string& yamlText, + std::string& outEnvironmentPath) + { + YAML::Node root; + try { + root = YAML::Load(yamlText); + } + catch (const YAML::Exception& e) { + ENGINE_ERROR("SceneSerializer: snapshot parse failed ({})", + e.what()); + return false; + } + catch (const std::exception& e) { + ENGINE_ERROR("SceneSerializer: snapshot parse failed ({})", + e.what()); + return false; + } + return ParseRoot(scene, assets, root, "snapshot", outEnvironmentPath); + } + } // namespace Engine::Serialization