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.
This commit is contained in:
2026-10-04 02:22:49 +08:00
parent c949e8684b
commit 2577809ee4
6 changed files with 291 additions and 59 deletions
+34 -5
View File
@@ -152,7 +152,10 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
selected != entt::null
? reg.try_get<ECS::TransformComponent>(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<entt::entity> 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<entt::entity> 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<entt::entity> 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<ECS::RigidBodyComponent>(selected);
rb != nullptr &&