diff --git a/docs/plan/Unity式Tick系统与PlayMode运行时方案-阶段进展.md b/docs/plan/Unity式Tick系统与PlayMode运行时方案-阶段进展.md new file mode 100644 index 00000000..97041bb2 --- /dev/null +++ b/docs/plan/Unity式Tick系统与PlayMode运行时方案-阶段进展.md @@ -0,0 +1,35 @@ +# Unity式 Tick 系统与 Play Mode 运行时方案阶段进展 + +日期:2026-04-02 + +## 已完成 + +### 阶段 A + +- 已接入 `RuntimeLoop`,统一承载 `FixedUpdate / Update / LateUpdate` +- 已接入 `PlaySessionController` +- 已实现 `Play / Stop` +- Play 时运行 runtime scene clone +- Stop 时恢复 editor scene snapshot +- `Run` 菜单与 `F5` 已可切换 `Play / Stop` + +### 阶段 B 当前收口 + +- 明确区分“文档级编辑”和“运行态场景对象编辑” +- `New/Open/Save Scene` 与 `New/Open/Save Project` 仍只允许在 `Edit` 下执行 +- `Play / Paused` 下允许对 runtime scene 进行对象级编辑与 `Undo / Redo` +- runtime scene 的对象改动默认不再污染场景文档 dirty 状态 + +## 当前语义 + +- `editor tick` 负责托管运行时会话 +- `engine tick` 负责推进 runtime world +- Play 时 `Hierarchy / Inspector / SceneView / GameView` 面对的是同一份 runtime world +- Play 中对对象的改动默认是临时运行态改动,Stop 后回滚 +- Play 中禁止的是文档切换与文档保存,不是禁止观察或编辑 runtime clone + +## 下一阶段建议 + +- 补全 `Pause / Resume / Step` 的完整状态机 +- 明确 `Paused` 下的 `Undo / Redo / Gizmo / Inspector` 交互语义 +- 将 `Error Pause` 完整并入正式状态机 diff --git a/docs/plan/Unity式Tick系统与PlayMode运行时方案.md b/docs/plan/Unity式Tick系统与PlayMode运行时方案.md new file mode 100644 index 00000000..1b9d42d8 --- /dev/null +++ b/docs/plan/Unity式Tick系统与PlayMode运行时方案.md @@ -0,0 +1,1054 @@ +# Unity 式 Tick 系统与 Play Mode 运行时方案 + +日期:4.2 + +## 1. 背景与问题定义 + +当前仓库已经具备一批运行时生命周期原语: + +- `Application` 已经能稳定提供 editor 每帧的 `deltaTime` +- `SceneRuntime` 已经具备 `Start / Stop / FixedUpdate / Update / LateUpdate` +- `Scene` 与 `GameObject` 已经能向原生组件继续分发生命周期 +- `ScriptEngine` 已经能把脚本生命周期接到运行时场景上 +- `SceneManager` 已经具备场景序列化、快照恢复与启动场景加载能力 + +因此,当前缺的并不是“有没有 `Update(float dt)` 这个函数”,而是: + +1. editor 模式下,谁来决定什么时候进入运行时 +2. Play 时跑的是哪一份场景 +3. Pause / Step / Stop 的边界由谁维护 +4. editor 自己的帧循环与 engine 运行时循环如何嵌套 +5. 运行时对 scene / input / audio / scripting / viewport 的驱动顺序如何统一 + +如果只是在 editor 每帧里直接调用 `Scene::Update(dt)`,短期看像是“加了 tick”,但长期会立刻引出几个错误方向: + +- 编辑场景被运行时修改污染 +- Stop 后无法恢复编辑状态 +- Hierarchy / Inspector / Undo / Dirty 状态混杂 +- GameView 和 SceneView 看到的不是同一个 runtime world +- Pause / Step 无法建立稳定语义 +- 后续独立 player 无法复用 editor 中临时写死的调度逻辑 + +所以这里必须把“tick”提升为一套 **Unity 式运行时主循环方案**,而不是零散的每帧调用。 + +--- + +## 2. 目标 + +本方案的目标是建立一套与 Unity 语义对齐的运行时驱动体系。 + +### 2.1 功能目标 + +- 明确区分 `editor tick` 与 `engine tick` +- 在 editor 内支持 `Edit / Play / Pause / Step / Simulate` +- Play 时运行的是 **runtime scene 副本**,而不是编辑场景本体 +- 统一驱动 `FixedUpdate / Update / LateUpdate` +- 让 SceneView / GameView / Inspector / Hierarchy 在 Play 中观察同一份 runtime world +- 为未来独立 player 复用同一套 runtime loop 打基础 + +### 2.2 架构目标 + +- 真正的 runtime loop 逻辑应放在 `engine/`,而不是散在 `editor/` +- editor 只负责“托管运行时会话”,不复制第二套引擎 +- scene 文档管理和 runtime 执行管理要严格分层 +- Play/Stop 的数据边界必须显式,不允许隐式回写编辑态 + +### 2.3 非目标 + +本方案第一阶段不解决以下问题: + +- 完整物理系统 +- 完整 Time API,如 `fixedDeltaTime`、`timeScale`、`unscaledDeltaTime` +- 全量输入系统 editor 宿主接线 +- 全量音频系统 editor 生命周期接线 +- Hot Reload / Script Recompile During Play +- Prefab / Domain Reload / Enter Play Mode Options + +这些能力可以在 Tick 体系稳定后继续扩展,但不能反过来阻塞 Tick 方案本身。 + +--- + +## 3. 核心概念定义 + +为了避免后续讨论混淆,本方案先固定术语。 + +### 3.1 editor tick + +`editor tick` 指编辑器自己的每帧更新。 + +它始终存在,只要 editor 窗口还在运行,就会继续执行。它负责: + +- 面板更新 +- 输入焦点与 UI 交互 +- 资源异步刷新 +- viewport 请求与宿主渲染 +- PlaySession 的状态推进 + +它不等价于 gameplay simulation。 + +### 3.2 engine tick + +`engine tick` 指引擎运行时自己的每帧逻辑循环,也就是 Unity 语义里的 PlayerLoop。 + +它只应在以下场景出现: + +- editor 的 `Play` +- editor 的 `Simulate` +- 将来的独立 runtime/player 程序 + +它负责: + +- 固定步长逻辑推进 +- 每帧逻辑推进 +- 脚本生命周期分发 +- 原生组件生命周期分发 +- 与运行时相关的系统刷新 + +### 3.3 runtime scene + +`runtime scene` 指进入 Play/Simulate 后真正被 engine tick 驱动的场景副本。 + +它具备以下特征: + +- 从编辑场景复制而来 +- 允许运行时自由修改 +- Stop 后整体丢弃 +- 不能直接污染编辑场景 + +### 3.4 editor scene + +`editor scene` 指当前用户正在编辑的场景文档。 + +它具备以下特征: + +- 持久化来源是 scene 文件 +- 与 Undo / Dirty / Save 强绑定 +- 在 Edit 模式中由 Inspector / Hierarchy / SceneView 操作 +- Play 期间不应被 runtime 直接修改 + +### 3.5 Play Session + +`Play Session` 指 editor 内一次完整的运行时会话。 + +它应至少覆盖: + +- 进入 Play 前快照编辑态 +- 构造 runtime scene +- 启动 runtime +- 推进 Pause / Step / Stop 状态 +- 退出时恢复编辑态 + +--- + +## 4. Unity 语义下 editor tick 与 engine tick 的关系 + +这一点必须讲清楚,因为这是整个方案的基础。 + +### 4.1 它们不是同一个东西 + +如果它们是同一个东西,那么游戏 Pause 时 editor 也应该跟着停掉。 + +但 Unity 不是这样: + +- Pause 游戏后,Hierarchy 还能选对象 +- Inspector 还能看 runtime 状态 +- SceneView 还能绕相机 +- Console 还能操作 +- 菜单栏和工具栏仍然活着 + +因此,暂停的是 gameplay loop,不是 editor host loop。 + +同理,在本引擎里也应该遵守相同原则: + +- editor tick 是宿主 +- engine tick 是被宿主管理的一段运行时循环 + +### 4.2 正确的嵌套关系 + +正确关系应为: + +`Win32 Loop -> Application::Render -> EditorWorkspace::Update(dt) -> PlaySession::Tick(dt) -> RuntimeLoop::Tick(dt)` + +也就是说: + +- 最外层永远是 editor +- editor 每帧判断当前是不是 Play/Simulate +- 若不是,则不推进 runtime loop +- 若是,则由 editor 在本帧内部托管调用 runtime loop + +### 4.3 三种模式下的关系 + +#### Edit + +- 有 editor tick +- 没有 engine tick +- 当前 active scene 是 editor scene + +#### Play + +- 有 editor tick +- 有 engine tick +- 当前 active scene 是 runtime scene +- SceneView 与 GameView 都观察 runtime world + +#### Simulate + +- 有 editor tick +- 有 engine tick +- 当前 active scene 仍是 runtime scene +- 与 Play 的主要区别不在“场景是否运行”,而在“使用哪种观察与交互语义” + +--- + +## 5. 当前代码状态与缺口归纳 + +### 5.1 当前已经具备的条件 + +#### editor 外层帧循环 + +当前 editor 已有稳定主循环: + +- `Win32EditorHost` 持续调用 `Application::Render` +- `Application::Render` 已计算每帧 `deltaTime` +- `EditorLayer -> EditorWorkspace` 已能接收 `dt` + +这说明 **editor host tick 已存在**。 + +#### 生命周期分发原语 + +当前 engine 已具备: + +- `SceneRuntime::Start / Stop` +- `SceneRuntime::FixedUpdate / Update / LateUpdate` +- `Scene::Update / FixedUpdate / LateUpdate` +- `GameObject::Start / Update / FixedUpdate / LateUpdate` + +这说明 **engine tick 的底层生命周期原语已存在**。 + +#### 场景复制与恢复基础 + +当前 `SceneManager` 已具备: + +- 场景序列化到字符串 +- 基于字符串恢复场景 +- `SceneSnapshot` 抓取与恢复 + +这说明 **runtime scene 副本机制已经有足够基础,不需要从零发明**。 + +### 5.2 当前明确缺失的部分 + +#### 缺失 1:运行时会话控制器 + +当前还没有一个对象负责统一维护: + +- 当前 editor mode +- PlaySession 生命周期 +- runtime scene 的创建与销毁 +- Pause / Step / Stop 状态机 +- fixed timestep accumulator + +#### 缺失 2:runtime scene 与 editor scene 的显式所有权边界 + +当前大多数 editor UI 都通过 `SceneManager::GetScene()` 读取场景。 + +这意味着如果 Play 期间仍然让 `GetScene()` 指向编辑场景,就会直接污染编辑态。 + +#### 缺失 3:真正统一的 runtime tick 调度入口 + +当前 `EditorWorkspace::Update(dt)` 只做: + +- `ResourceManager::UpdateAsyncLoads()` +- `m_panels.UpdateAll(dt)` + +还没有推进: + +- `SceneRuntime` +- fixed-step accumulator +- runtime-specific system flush + +#### 缺失 4:Play/Pause/Stop 事件只有定义,没有闭环 + +当前已有: + +- `PlayModeStartedEvent` +- `PlayModeStoppedEvent` +- `PlayModePausedEvent` + +但缺: + +- 谁发布 Started/Stopped +- 谁在 Pause 后阻止 runtime 更新 +- 谁在 Step 时只推进一帧或一次 fixed step + +#### 缺失 5:脚本后端在 editor 中未完成正式接线 + +当前 `MonoScriptRuntime` 实现已存在,但 editor 初始化路径中没有形成明确的 runtime 安装流程。 + +这意味着即使补上 PlaySession,如果不同时补脚本后端接线,Play 下的脚本生命周期仍可能只落到 `NullScriptRuntime`。 + +#### 缺失 6:输入 / 音频 / 任务系统接线不完整 + +当前代码中虽然有: + +- `InputManager::Update` +- `AudioSystem::Update` +- `TaskSystem::Update` + +但 editor 生命周期中还没有完整初始化和宿主消息接线,因此第一版 Tick 方案必须区分: + +- 哪些系统是架构上应该接入的 +- 哪些系统是当前阶段先预留挂点 + +--- + +## 6. 总体架构方案 + +### 6.1 分层原则 + +本方案采用两层控制: + +1. `engine/` 内的 **RuntimeLoop** +2. `editor/` 内的 **PlaySessionController** + +其职责必须严格区分。 + +### 6.2 engine 层:RuntimeLoop + +建议新增一个 runtime loop 控制器,放在 `engine/Scene` 或 `engine/Runtime` 相关目录下。 + +它负责纯运行时行为,不依赖任何 editor 类型。 + +#### 职责 + +- 持有 `SceneRuntime` +- 持有 `running / paused` 状态 +- 持有 fixed timestep accumulator +- 接受宿主传入的 `dt` +- 统一调度 `FixedUpdate / Update / LateUpdate` +- 提供 `Tick(dt)`、`Pause()`、`Resume()`、`StepOnce()` + +#### 不负责 + +- 场景文件保存 +- editor Undo/Dirty +- 面板 UI +- GameView 工具栏状态 +- 项目切换 + +#### 建议接口 + +```cpp +class RuntimeLoop { +public: + struct Settings { + float fixedDeltaTime = 1.0f / 50.0f; + float maxFrameDeltaTime = 0.1f; + uint32_t maxFixedStepsPerFrame = 4; + }; + + void Start(Components::Scene* scene); + void Stop(); + + void Tick(float deltaTime); + void Pause(); + void Resume(); + void StepFrame(); + + bool IsRunning() const; + bool IsPaused() const; + Components::Scene* GetScene() const; +}; +``` + +### 6.3 editor 层:PlaySessionController + +建议新增 editor 侧运行时会话控制器,例如: + +- `editor/src/Core/PlaySessionController.h` +- 或 `editor/src/Core/EditorRuntimeController.h` + +它负责 editor 与 runtime 的边界管理。 + +#### 职责 + +- 维护 `Edit / Play / Pause / Simulate` 模式 +- 抓取编辑场景快照 +- 创建 runtime scene 副本 +- 切换 `SceneManager` 当前 active scene +- 调用 `RuntimeLoop::Start / Stop / Tick` +- 发布 Play/Pause/Stop 事件 +- Stop 时恢复编辑场景与 dirty 状态 + +#### 不负责 + +- 不实现具体生命周期分发 +- 不直接遍历 `GameObject` +- 不替代 `SceneManager` + +### 6.4 SceneManager 的角色 + +`SceneManager` 仍然是场景文档与当前 active scene 的管理入口。 + +本方案不建议新增第二个并行 scene manager。 + +建议保留单一 active scene 语义,但增加“当前 scene 处于 editor 还是 runtime 模式”的管理逻辑。 + +这样可以保证: + +- Hierarchy 读取的是当前正在展示的 scene +- Inspector 读取的是当前正在运行或编辑的对象 +- SceneView / GameView 都基于同一份 scene + +### 6.5 为什么不让 GameView 单独持有 runtime scene + +这种设计看起来“侵入更小”,但实际会把 editor 撕成两套世界: + +- GameView 看 runtime scene +- Hierarchy / Inspector / SceneView 还看 editor scene + +这与 Unity Play Mode 的真实使用习惯相反,也会带来以下问题: + +- 运行中对象在 GameView 里变了,但 Hierarchy 看不到 +- 选中对象后 Inspector 读到的是编辑态,不是运行态 +- SceneView 无法自然观察 runtime 世界 + +因此第一版正确路线应是: + +**Play 时让 editor 当前 active scene 切到 runtime clone。** + +--- + +## 7. 场景所有权与数据流方案 + +### 7.1 编辑态与运行态必须双轨 + +在任何时刻,editor 都必须知道自己面对的是哪一类 scene: + +- `editor scene` +- `runtime scene` + +运行态和编辑态不能混存于同一份对象图中。 + +### 7.2 进入 Play 的标准流程 + +建议流程如下: + +1. 确认当前有 active scene +2. 抓取 editor scene 快照 +3. 基于快照数据构造新的 runtime scene +4. 暂存当前 selection、dirty、scene path 等 editor 上下文 +5. 将 `SceneManager` 当前 active scene 切到 runtime scene +6. 启动 `RuntimeLoop` +7. 发布 `PlayModeStartedEvent` + +### 7.3 停止 Play 的标准流程 + +建议流程如下: + +1. 调用 `RuntimeLoop::Stop()` +2. 销毁 runtime scene +3. 用 Play 前保存的快照恢复 editor scene +4. 恢复 dirty 状态、scene path、scene name +5. 清理 runtime-only 选择状态 +6. 发布 `PlayModeStoppedEvent` +7. 回到 `Edit` 模式 + +### 7.4 为什么快照恢复是第一版最合适方案 + +当前仓库已有: + +- `SerializeToString` +- `DeserializeFromString` +- `SceneSnapshot` + +这意味着第一版根本不需要发明复杂的“对象级镜像同步层”。 + +对于 Unity 式 Play 行为来说,最符合直觉的方式就是: + +- Play 前冻结编辑态 +- Play 中任 runtime 随便改 +- Stop 后整份 runtime world 丢弃 +- 恢复编辑态 + +这比尝试在 Stop 时“回滚运行时改动”更稳,也更接近 Unity。 + +### 7.5 Dirty 与 Save 语义 + +Play 期间的 runtime 改动不应自动标记编辑场景 dirty。 + +需要明确以下规则: + +- 进入 Play 前,dirty 状态是多少,就记录多少 +- Play 期间 runtime world 的变化不回写 editor dirty +- Stop 后恢复 Play 前 dirty 状态 + +这样可以避免: + +- 只是运行一次游戏,却把场景误判成已修改 +- runtime 中脚本动态生成对象导致 editor scene 被误保存 + +--- + +## 8. 模式状态机设计 + +### 8.1 状态定义 + +建议 editor 模式至少定义如下枚举: + +```cpp +enum class EditorRuntimeMode { + Edit, + Play, + Paused, + Simulate +}; +``` + +第一阶段即使只正式实现 `Edit / Play / Paused`,也建议把 `Simulate` 预留到状态机层,而不是后面另起一套逻辑。 + +### 8.2 状态行为语义 + +#### Edit + +- 当前 active scene 为 editor scene +- 不推进 runtime loop +- 允许完整编辑 + +#### Play + +- 当前 active scene 为 runtime scene +- 推进 runtime loop +- 对高风险编辑行为进行限制 + +#### Paused + +- 当前 active scene 仍为 runtime scene +- 不自动推进 runtime loop +- 允许观察运行时状态 +- 可通过 Step 手动推进 + +#### Simulate + +- 当前 active scene 为 runtime scene +- 推进 runtime loop +- 主要差异体现在观察相机和工具行为 + +### 8.3 状态转换 + +建议只允许以下显式转换: + +- `Edit -> Play` +- `Play -> Paused` +- `Paused -> Play` +- `Play -> Edit` +- `Paused -> Edit` +- `Edit -> Simulate` +- `Simulate -> Edit` + +不建议存在: + +- 隐式自动恢复 +- 运行时错误直接把模式切回 Edit + +运行时错误更合理的做法是: + +- 触发 Pause +- 在 Console 标记错误 +- 保留 runtime world 供观察 + +这与 Unity 的调试体验更接近。 + +--- + +## 9. 主循环与 Tick 顺序方案 + +### 9.1 顶层顺序 + +editor 每帧顺序建议为: + +1. 计算 host `deltaTime` +2. 更新 editor 级输入与宿主状态 +3. 推进 PlaySession +4. 更新面板逻辑 +5. 渲染 SceneView / GameView +6. 提交 ImGui 与窗口交换链 + +其中第 3 步内部若处于 `Play / Simulate`,再调用 runtime loop。 + +### 9.2 RuntimeLoop 的单帧顺序 + +建议 `RuntimeLoop::Tick(dt)` 顺序如下: + +1. 对 `dt` 做上限钳制 +2. 把 `dt` 累加到 fixed accumulator +3. 循环执行若干次 `FixedUpdate(fixedDeltaTime)` +4. 执行一次 `Update(dt)` +5. 执行一次 `LateUpdate(dt)` +6. 运行帧尾系统刷新 + +伪代码如下: + +```cpp +void RuntimeLoop::Tick(float dt) { + if (!m_running || m_scene == nullptr) { + return; + } + + if (m_paused) { + if (!m_stepRequested) { + return; + } + } + + dt = Clamp(dt, 0.0f, m_settings.maxFrameDeltaTime); + m_fixedAccumulator += dt; + + uint32_t fixedSteps = 0; + while (m_fixedAccumulator >= m_settings.fixedDeltaTime && + fixedSteps < m_settings.maxFixedStepsPerFrame) { + m_sceneRuntime.FixedUpdate(m_settings.fixedDeltaTime); + m_fixedAccumulator -= m_settings.fixedDeltaTime; + ++fixedSteps; + } + + m_sceneRuntime.Update(dt); + m_sceneRuntime.LateUpdate(dt); + + m_stepRequested = false; +} +``` + +### 9.3 为什么必须有 fixed accumulator + +如果没有它: + +- `FixedUpdate` 就会退化成“和 `Update` 一样每帧一次” +- Pause / Step 没法建立稳定语义 +- 未来物理系统没有可靠接入点 +- 帧率波动会直接改变 fixed 逻辑行为 + +这与 Unity 模型不一致。 + +### 9.4 为什么要限制每帧最大 fixed 步数 + +这是为了避免“螺旋死亡”: + +- 某帧卡顿导致 `dt` 很大 +- 一次性补太多 `FixedUpdate` +- 补固定步本身又导致更卡 +- 进入恶性循环 + +因此必须有: + +- `maxFrameDeltaTime` +- `maxFixedStepsPerFrame` + +### 9.5 Start 与 Update 的顺序 + +当前 `SceneRuntime::Update` 内部会先进入脚本更新,再进入 scene/gameobject 的 `Start` 与原生 `Update` 分发。 + +这一行为当前已有测试兜底,但后续在形成正式 runtime loop 后,仍建议把 **生命周期顺序作为公开契约** 固化下来。 + +对于第一阶段方案,本文件接受当前行为,后续若要进一步对齐 Unity 的更细语义,可以单独再做生命周期整理,不阻塞 Tick 方案落地。 + +--- + +## 10. 系统接入顺序 + +### 10.1 第一阶段必须接入 + +第一阶段真正必须接入 runtime tick 的系统只有: + +- `SceneRuntime` +- `ScriptEngine`(通过 `SceneRuntime` 间接接入) +- `Scene / GameObject / Component` 生命周期 + +这是形成最小 Unity 式 Play 语义闭环的核心。 + +### 10.2 第一阶段建议预留但不强制打通 + +以下系统建议在 RuntimeLoop 中预留挂点,但可以先不完整接通: + +- `InputManager` +- `AudioSystem` +- `TaskSystem` + +原因不是它们不重要,而是它们当前在 editor 生命周期中还未完整初始化。 + +因此第一阶段合理策略是: + +- 架构上预留顺序和位置 +- 实现上只接已初始化的系统 +- 未初始化时安全跳过 + +### 10.3 建议顺序 + +如果这些系统后续要正式进入 runtime tick,建议顺序为: + +1. 宿主输入消息先进入输入模块 +2. runtime loop 读取上一帧积累的输入状态 +3. 运行 `FixedUpdate` +4. 运行 `Update` +5. 运行 `LateUpdate` +6. 刷新音频 +7. 执行主线程任务队列 +8. 清理本帧输入瞬时态 + +第一阶段若尚未把 editor 消息正式接到引擎输入模块,则不要伪造“已接通输入”。 + +--- + +## 11. Play、Pause、Step 的精确定义 + +### 11.1 Play + +`Play` 的语义不是“开始渲染 GameView”,而是: + +- 构造 runtime scene +- 启动 runtime loop +- 开始推进生命周期 + +GameView 只是 runtime world 的一个观察窗口。 + +### 11.2 Pause + +`Pause` 的语义不是“冻结 editor”,而是: + +- 停止自动推进 runtime loop +- 保留 runtime scene 当前状态 +- 允许用户继续观察运行态对象图 + +这点与 Unity 保持一致。 + +### 11.3 Step + +`Step` 的语义建议定义为: + +- 在 `Paused` 状态下推进 **一帧完整 runtime tick** +- 即允许本次执行固定步补偿、一次 `Update`、一次 `LateUpdate` + +而不是只推进一次 `Update`。 + +原因如下: + +- 更符合 Unity 使用者对 Step 的直觉 +- 对脚本与未来物理更一致 +- 避免“Step 看起来动了,但 FixedUpdate 没跑”的语义分裂 + +### 11.4 Error Pause + +Console 中已有 `Error Pause` 语义雏形。 + +建议正式方案中把它收敛为: + +- 监听运行时错误 +- 当处于 Play 且开启 `Error Pause` 时,自动切换到 `Paused` +- 不自动 Stop + +这更便于排查 runtime 状态。 + +--- + +## 12. 对 editor 各模块的影响 + +### 12.1 SceneManager + +需要增强但不重写。 + +建议新增能力: + +- 进入 runtime scene 的接口 +- 恢复 editor scene 的接口 +- 查询当前 scene 是否为 runtime scene + +不建议让多个模块自行持有 scene 指针并绕开 `SceneManager`。 + +### 12.2 Hierarchy + +Play 时 Hierarchy 应展示 runtime scene 中的对象树。 + +这样才能做到: + +- 运行时动态生成对象可见 +- 运行时销毁对象可见 +- 选中对象后 Inspector 读到的是运行态 + +### 12.3 Inspector + +Play 时 Inspector 应直接观察 runtime object/component 状态。 + +同时需要明确编辑策略: + +- 第一阶段允许查看与轻量编辑 runtime 值 +- 但这些修改默认只作用于 runtime scene +- Stop 后不自动回写 editor scene + +### 12.4 SceneView + +Play 时 SceneView 不应继续盯着 editor scene。 + +它应观察 runtime world,但仍然使用 editor camera 语义。 + +这正是 Unity 中 SceneView 在 Play 时的常见行为。 + +### 12.5 GameView + +GameView 在 Play 时应渲染 runtime scene 中的运行时 camera。 + +这一点当前 viewport host 的基本结构已具备,关键在于它取到的 scene 必须是 runtime clone,而不是 editor scene。 + +### 12.6 Undo / Dirty + +Play 时 runtime 修改默认不进入 editor undo 历史。 + +原因: + +- Unity 风格下,Play 期间改的是 runtime world +- runtime world 本身是临时态 +- 把 runtime 改动混进 editor undo 会破坏编辑语义 + +因此第一阶段建议: + +- `Edit` 模式下:正常 Undo/Dirty +- `Play / Pause / Simulate` 下:对 editor 文档型命令做限制或隔离 + +--- + +## 13. 实现方案细化 + +### 13.1 建议新增类型 + +#### engine 层 + +- `RuntimeLoop` + +#### editor 层 + +- `PlaySessionController` +- `EditorRuntimeMode` +- `PlaySessionState` + +### 13.2 PlaySessionState 建议字段 + +```cpp +struct PlaySessionState { + EditorRuntimeMode mode = EditorRuntimeMode::Edit; + + SceneSnapshot editorSnapshot; + std::unique_ptr runtimeScene; + + bool hasSession = false; + bool stepRequested = false; + + std::string sourceScenePath; + std::string sourceSceneName; + bool sourceSceneDirty = false; +}; +``` + +### 13.3 RuntimeLoop 建议字段 + +```cpp +class RuntimeLoop { +private: + SceneRuntime m_sceneRuntime; + Components::Scene* m_scene = nullptr; + + Settings m_settings = {}; + float m_fixedAccumulator = 0.0f; + + bool m_running = false; + bool m_paused = false; + bool m_stepRequested = false; +}; +``` + +### 13.4 EditorWorkspace 中的挂载点 + +建议在 `EditorWorkspace` 内持有 `PlaySessionController`,而不是在各个 panel 分散维护。 + +理由: + +- `EditorWorkspace` 已是 editor panel 与 context 的调度层 +- 它比单个 panel 更适合承接全局运行态 +- 它比 `Application` 更贴近 editor 业务层,但又不直接陷入 UI 细节 + +建议每帧顺序变为: + +1. 资源异步刷新 +2. `PlaySessionController.Update(dt)` +3. `m_panels.UpdateAll(dt)` + +### 13.5 Application 层的责任变化 + +`Application` 不应直接拥有 runtime scene 或 runtime loop。 + +它只应继续负责: + +- 计算 host `dt` +- 调用 layer / workspace update +- 驱动 viewport 渲染 + +这样未来独立 runtime 程序仍然可以重用 engine runtime loop,而无需依赖 editor 程序对象。 + +--- + +## 14. 分阶段实施建议 + +### 阶段 A:建立 runtime loop 与最小 PlaySession + +目标: + +- 新增 `RuntimeLoop` +- 新增 `PlaySessionController` +- 支持 `Play` 与 `Stop` +- Play 时切到 runtime scene +- Stop 时恢复 editor snapshot + +这一阶段先不要求: + +- Pause / Step UI 完整 +- Simulate 正式可用 +- 输入/音频全接通 + +### 阶段 B:补 Pause / Step / 状态机闭环 + +目标: + +- 正式接 `Paused` +- 正式实现 `Step` +- 完整发布 Started / Paused / Stopped 事件 +- Console 的 `Error Pause` 改为连接正式状态机 + +### 阶段 C:补脚本后端 editor 接线 + +目标: + +- editor 启动时根据配置安装 `MonoScriptRuntime` +- Play 时脚本生命周期真实运行 +- 运行时错误进入 Console + +### 阶段 D:补输入与 GameView 可操作性 + +目标: + +- editor 宿主窗口消息接入引擎输入模块 +- runtime tick 可读取真实输入 +- 基础 `Time.deltaTime` 与输入行为在 Play 中可用 + +### 阶段 E:扩展 Simulate 与更完整 Time 语义 + +目标: + +- `Simulate` 正式与 `Play` 分化 +- 增加 `Time.fixedDeltaTime` +- 增加 `timeScale` +- 为物理系统预留更明确挂点 + +--- + +## 15. 风险与边界 + +### 15.1 最大风险:把 runtime 改动污染 editor scene + +这是整个方案最需要防的风险。 + +只要 runtime scene 与 editor scene 边界不清,就会导致: + +- Stop 后场景数据错乱 +- Undo/Dirty 语义崩塌 +- 用户无法信任 Play 行为 + +因此第一原则必须是: + +**Play 永远运行副本,不直接跑编辑场景。** + +### 15.2 第二风险:在 editor 里复制第二套 runtime 逻辑 + +如果把生命周期调度、输入推进、viewport runtime 判断等逻辑分散写在: + +- GameViewPanel +- SceneViewPanel +- ConsolePanel +- Application + +最终会得到一套 editor 私有 runtime。 + +这会严重偏离“editor 是宿主,不是第二个引擎”的原则。 + +因此真正的 runtime 调度逻辑必须尽可能收敛到 `engine/RuntimeLoop`。 + +### 15.3 第三风险:第一阶段试图一次打通所有系统 + +如果把以下内容强行绑成一个任务: + +- PlaySession +- Mono 正式接线 +- 输入宿主化 +- 音频接线 +- Step +- Simulate +- Time API 扩展 + +实现风险和调试复杂度都会迅速失控。 + +因此应坚持: + +- 第一阶段先形成 `Play/Stop + runtime clone + SceneRuntime tick` 闭环 +- 其他系统按阶段接入 + +--- + +## 16. 测试建议 + +### 16.1 engine 层单测 + +新增或扩展以下测试: + +- `RuntimeLoop` 在 running / paused / step 下的推进行为 +- fixed accumulator 在不同 `dt` 下的补偿行为 +- `maxFixedStepsPerFrame` 的限制行为 +- Stop 后不再继续分发生命周期 + +### 16.2 editor 层单测 + +新增或扩展以下测试: + +- EnterPlay 会创建 runtime scene 副本 +- Stop 会恢复 editor snapshot +- Play 期间 active scene 指向 runtime scene +- Play 期间 runtime 改动不污染 editor snapshot +- Pause 后不自动推进 +- Step 在 Pause 下能推进一帧 + +### 16.3 手工验证清单 + +第一阶段至少手工验证: + +1. 进入 Play 后,脚本或原生组件的运行时移动只影响 Play 中对象 +2. Stop 后场景恢复到 Play 前状态 +3. Play 中动态创建的对象只在 runtime world 中存在 +4. Hierarchy / Inspector / SceneView / GameView 看到的是同一份 runtime world +5. Play 后保存场景不会把 runtime 临时对象保存进去 + +--- + +## 17. 最终建议与结论 + +对于当前仓库,最合理的 `tick` 建设路线不是“在 editor 每帧里直接调一次 `Scene::Update`”,而是: + +1. 在 `engine/` 建立独立的 `RuntimeLoop` +2. 在 `editor/` 建立 `PlaySessionController` +3. Play 时把 active scene 切到 runtime scene 副本 +4. 用 `RuntimeLoop` 统一推进 `FixedUpdate / Update / LateUpdate` +5. Stop 时恢复 editor snapshot + +这条路线的核心收益有三点: + +- 它与 Unity 的 Editor/Player 语义一致 +- 它能保证 editor scene 与 runtime scene 的边界清晰 +- 它为未来独立 runtime/player 复用同一套主循环打下基础 + +简化成一句话就是: + +**editor tick 是宿主循环,engine tick 是运行时循环;在 Unity 式架构下,前者在 Play 时托管后者,但两者绝不能被当成同一个东西。** diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 124ca878..9020786c 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -65,6 +65,7 @@ add_executable(${PROJECT_NAME} WIN32 src/Application.cpp src/Theme.cpp src/Core/UndoManager.cpp + src/Core/PlaySessionController.cpp src/ComponentEditors/ComponentEditorRegistry.cpp src/Managers/SceneManager.cpp src/Managers/ProjectManager.cpp @@ -78,7 +79,9 @@ add_executable(${PROJECT_NAME} WIN32 src/Viewport/SceneViewportRotateGizmo.cpp src/Viewport/SceneViewportScaleGizmo.cpp src/Viewport/SceneViewportOrientationGizmo.cpp + src/Viewport/SceneViewportOverlayBuilder.cpp src/Viewport/SceneViewportOverlayRenderer.cpp + src/Viewport/Passes/SceneViewportEditorOverlayPass.cpp src/panels/GameViewPanel.cpp src/panels/InspectorPanel.cpp src/panels/ConsolePanel.cpp diff --git a/editor/src/Actions/EditorActions.h b/editor/src/Actions/EditorActions.h index 81806f13..06e59a96 100644 --- a/editor/src/Actions/EditorActions.h +++ b/editor/src/Actions/EditorActions.h @@ -16,48 +16,60 @@ namespace XCEngine { namespace Editor { namespace Actions { -inline ActionBinding MakeNewProjectAction() { - return MakeAction("New Project..."); +inline ActionBinding MakeNewProjectAction(bool enabled = true) { + return MakeAction("New Project...", nullptr, false, enabled); } -inline ActionBinding MakeOpenProjectAction() { - return MakeAction("Open Project..."); +inline ActionBinding MakeOpenProjectAction(bool enabled = true) { + return MakeAction("Open Project...", nullptr, false, enabled); } -inline ActionBinding MakeSaveProjectAction() { - return MakeAction("Save Project"); +inline ActionBinding MakeSaveProjectAction(bool enabled = true) { + return MakeAction("Save Project", nullptr, false, enabled); } -inline ActionBinding MakeNewSceneAction() { - return MakeAction("New Scene", "Ctrl+N", false, true, true, Shortcut(ImGuiKey_N, true)); +inline ActionBinding MakeNewSceneAction(bool enabled = true) { + return MakeAction("New Scene", "Ctrl+N", false, enabled, true, Shortcut(ImGuiKey_N, true)); } -inline ActionBinding MakeOpenSceneAction() { - return MakeAction("Open Scene", "Ctrl+O", false, true, true, Shortcut(ImGuiKey_O, true)); +inline ActionBinding MakeOpenSceneAction(bool enabled = true) { + return MakeAction("Open Scene", "Ctrl+O", false, enabled, true, Shortcut(ImGuiKey_O, true)); } -inline ActionBinding MakeSaveSceneAction() { - return MakeAction("Save Scene", "Ctrl+S", false, true, true, Shortcut(ImGuiKey_S, true)); +inline ActionBinding MakeSaveSceneAction(bool enabled = true) { + return MakeAction("Save Scene", "Ctrl+S", false, enabled, true, Shortcut(ImGuiKey_S, true)); } -inline ActionBinding MakeSaveSceneAsAction() { - return MakeAction("Save Scene As...", "Ctrl+Shift+S", false, true, true, Shortcut(ImGuiKey_S, true, true)); +inline ActionBinding MakeSaveSceneAsAction(bool enabled = true) { + return MakeAction( + "Save Scene As...", + "Ctrl+Shift+S", + false, + enabled, + true, + Shortcut(ImGuiKey_S, true, true)); } inline ActionBinding MakeUndoAction(IEditorContext& context) { auto& undoManager = context.GetUndoManager(); + const bool enabled = + IsEditorSceneUndoRedoAllowed(context.GetRuntimeMode()) && + undoManager.CanUndo(); const std::string label = undoManager.CanUndo() ? "Undo " + undoManager.GetUndoLabel() : "Undo"; - return MakeAction(label, "Ctrl+Z", false, undoManager.CanUndo(), false, Shortcut(ImGuiKey_Z, true)); + return MakeAction(label, "Ctrl+Z", false, enabled, false, Shortcut(ImGuiKey_Z, true)); } inline ActionBinding MakeRedoAction(IEditorContext& context) { auto& undoManager = context.GetUndoManager(); + const bool enabled = + IsEditorSceneUndoRedoAllowed(context.GetRuntimeMode()) && + undoManager.CanRedo(); const std::string label = undoManager.CanRedo() ? "Redo " + undoManager.GetRedoLabel() : "Redo"; return MakeAction( label, "Ctrl+Y", false, - undoManager.CanRedo(), + enabled, false, Shortcut(ImGuiKey_Y, true), Shortcut(ImGuiKey_Z, true, true)); @@ -125,10 +137,22 @@ inline ActionBinding MakeCreateSphereEntityAction() { return MakeAction("Sphere"); } +inline ActionBinding MakeCreateCapsuleEntityAction() { + return MakeAction("Capsule"); +} + +inline ActionBinding MakeCreateCylinderEntityAction() { + return MakeAction("Cylinder"); +} + inline ActionBinding MakeCreatePlaneEntityAction() { return MakeAction("Plane"); } +inline ActionBinding MakeCreateQuadEntityAction() { + return MakeAction("Quad"); +} + inline ActionBinding MakeResetLayoutAction() { return MakeAction("Reset Layout"); } @@ -141,6 +165,17 @@ inline ActionBinding MakeExitAction() { return MakeAction("Exit", "Alt+F4"); } +inline ActionBinding MakeTogglePlayModeAction(EditorRuntimeMode mode, bool enabled = true) { + const bool active = IsEditorRuntimeActive(mode); + return MakeAction( + active ? "Stop" : "Play", + "F5", + active, + enabled, + false, + Shortcut(ImGuiKey_F5)); +} + inline ActionBinding MakeNavigateBackAction(bool enabled) { return MakeAction("<", "Alt+Left", false, enabled, false, Shortcut(ImGuiKey_LeftArrow, false, false, true)); } diff --git a/editor/src/Actions/MainMenuActionRouter.h b/editor/src/Actions/MainMenuActionRouter.h index 55e838b6..4c309a23 100644 --- a/editor/src/Actions/MainMenuActionRouter.h +++ b/editor/src/Actions/MainMenuActionRouter.h @@ -14,6 +14,10 @@ namespace XCEngine { namespace Editor { namespace Actions { +inline bool IsDocumentEditingAllowed(const IEditorContext& context) { + return IsEditorDocumentEditingAllowed(context.GetRuntimeMode()); +} + inline void ExecuteNewScene(IEditorContext& context) { Commands::NewScene(context); } @@ -58,33 +62,53 @@ inline void RequestDockLayoutReset(IEditorContext& context) { context.GetEventBus().Publish(DockLayoutResetRequestedEvent{}); } +inline void RequestTogglePlayMode(IEditorContext& context) { + if (context.GetRuntimeMode() == EditorRuntimeMode::Edit) { + context.GetEventBus().Publish(PlayModeStartRequestedEvent{}); + return; + } + + context.GetEventBus().Publish(PlayModeStopRequestedEvent{}); +} + inline void RequestAboutPopup(UI::DeferredPopupState& aboutPopup) { aboutPopup.RequestOpen(); } inline void HandleMainMenuShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) { - HandleShortcut(MakeNewSceneAction(), shortcutContext, [&]() { ExecuteNewScene(context); }); - HandleShortcut(MakeOpenSceneAction(), shortcutContext, [&]() { ExecuteOpenScene(context); }); - HandleShortcut(MakeSaveSceneAction(), shortcutContext, [&]() { ExecuteSaveScene(context); }); - HandleShortcut(MakeSaveSceneAsAction(), shortcutContext, [&]() { ExecuteSaveSceneAs(context); }); + const bool canEditDocuments = IsDocumentEditingAllowed(context); + HandleShortcut(MakeTogglePlayModeAction(context.GetRuntimeMode()), shortcutContext, [&]() { + RequestTogglePlayMode(context); + }); + HandleShortcut(MakeNewSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteNewScene(context); }); + HandleShortcut(MakeOpenSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteOpenScene(context); }); + HandleShortcut(MakeSaveSceneAction(canEditDocuments), shortcutContext, [&]() { ExecuteSaveScene(context); }); + HandleShortcut(MakeSaveSceneAsAction(canEditDocuments), shortcutContext, [&]() { ExecuteSaveSceneAs(context); }); HandleShortcut(MakeUndoAction(context), shortcutContext, [&]() { ExecuteUndo(context); }); HandleShortcut(MakeRedoAction(context), shortcutContext, [&]() { ExecuteRedo(context); }); HandleEditShortcuts(context, shortcutContext); } inline void DrawFileMenuActions(IEditorContext& context) { - DrawMenuAction(MakeNewProjectAction(), [&]() { ExecuteNewProject(context); }); - DrawMenuAction(MakeOpenProjectAction(), [&]() { ExecuteOpenProject(context); }); - DrawMenuAction(MakeSaveProjectAction(), [&]() { ExecuteSaveProject(context); }); + const bool canEditDocuments = IsDocumentEditingAllowed(context); + DrawMenuAction(MakeNewProjectAction(canEditDocuments), [&]() { ExecuteNewProject(context); }); + DrawMenuAction(MakeOpenProjectAction(canEditDocuments), [&]() { ExecuteOpenProject(context); }); + DrawMenuAction(MakeSaveProjectAction(canEditDocuments), [&]() { ExecuteSaveProject(context); }); DrawMenuSeparator(); - DrawMenuAction(MakeNewSceneAction(), [&]() { ExecuteNewScene(context); }); - DrawMenuAction(MakeOpenSceneAction(), [&]() { ExecuteOpenScene(context); }); - DrawMenuAction(MakeSaveSceneAction(), [&]() { ExecuteSaveScene(context); }); - DrawMenuAction(MakeSaveSceneAsAction(), [&]() { ExecuteSaveSceneAs(context); }); + DrawMenuAction(MakeNewSceneAction(canEditDocuments), [&]() { ExecuteNewScene(context); }); + DrawMenuAction(MakeOpenSceneAction(canEditDocuments), [&]() { ExecuteOpenScene(context); }); + DrawMenuAction(MakeSaveSceneAction(canEditDocuments), [&]() { ExecuteSaveScene(context); }); + DrawMenuAction(MakeSaveSceneAsAction(canEditDocuments), [&]() { ExecuteSaveSceneAs(context); }); DrawMenuSeparator(); DrawMenuAction(MakeExitAction(), [&]() { RequestEditorExit(context); }); } +inline void DrawRunMenuActions(IEditorContext& context) { + DrawMenuAction(MakeTogglePlayModeAction(context.GetRuntimeMode()), [&]() { + RequestTogglePlayMode(context); + }); +} + inline void DrawViewMenuActions(IEditorContext& context) { DrawMenuAction(MakeResetLayoutAction(), [&]() { RequestDockLayoutReset(context); }); } @@ -108,6 +132,9 @@ inline void DrawMainMenuBar(IEditorContext& context, UI::DeferredPopupState& abo UI::DrawMenuScope("Edit", [&]() { DrawEditActions(context); }); + UI::DrawMenuScope("Run", [&]() { + DrawRunMenuActions(context); + }); UI::DrawMenuScope("View", [&]() { DrawViewMenuActions(context); }); diff --git a/editor/src/Commands/ProjectCommands.h b/editor/src/Commands/ProjectCommands.h index 979f0b32..55f92022 100644 --- a/editor/src/Commands/ProjectCommands.h +++ b/editor/src/Commands/ProjectCommands.h @@ -1,6 +1,7 @@ #pragma once #include "Application.h" +#include "Platform/Win32Utf8.h" #include "Core/AssetItem.h" #include "Core/IEditorContext.h" #include "Core/IProjectManager.h" @@ -14,12 +15,17 @@ #include #include #include +#include #include namespace XCEngine { namespace Editor { namespace Commands { +inline bool IsProjectDocumentEditingAllowed(const IEditorContext& context) { + return IsEditorDocumentEditingAllowed(context.GetRuntimeMode()); +} + namespace detail { inline std::wstring MakeProjectPathKey(const std::filesystem::path& path) { @@ -75,6 +81,93 @@ inline AssetItemPtr CreateFolder(IProjectManager& projectManager, const std::str return projectManager.CreateFolder(name); } +inline AssetItemPtr CreateMaterial(IProjectManager& projectManager, const std::string& name) { + if (name.empty()) { + return nullptr; + } + + const AssetItemPtr currentFolder = projectManager.GetCurrentFolder(); + const AssetItemPtr rootFolder = projectManager.GetRootFolder(); + if (!currentFolder || !currentFolder->isFolder || !rootFolder) { + return nullptr; + } + + namespace fs = std::filesystem; + + try { + const std::string trimmedName = ProjectFileUtils::Trim(name); + if (trimmedName.empty()) { + return nullptr; + } + + const fs::path currentFolderPath = fs::path(currentFolder->fullPath); + const fs::path rootPath = fs::path(rootFolder->fullPath); + if (!fs::exists(currentFolderPath) || !fs::is_directory(currentFolderPath) || + !detail::IsSameOrDescendantProjectPath(currentFolderPath, rootPath)) { + return nullptr; + } + + fs::path requestedFileName = fs::path(trimmedName); + if (!requestedFileName.has_extension()) { + requestedFileName += ".mat"; + } + + fs::path materialPath = currentFolderPath / requestedFileName; + for (size_t suffix = 1; fs::exists(materialPath); ++suffix) { + materialPath = currentFolderPath / + fs::path(requestedFileName.stem().string() + " " + std::to_string(suffix) + requestedFileName.extension().string()); + } + + std::ofstream output(materialPath, std::ios::out | std::ios::trunc); + if (!output.is_open()) { + return nullptr; + } + + output << + "{\n" + " \"renderQueue\": \"geometry\",\n" + " \"renderState\": {\n" + " \"cull\": \"none\",\n" + " \"depthTest\": true,\n" + " \"depthWrite\": true,\n" + " \"depthFunc\": \"less\",\n" + " \"blendEnable\": false,\n" + " \"srcBlend\": \"one\",\n" + " \"dstBlend\": \"zero\",\n" + " \"srcBlendAlpha\": \"one\",\n" + " \"dstBlendAlpha\": \"zero\",\n" + " \"blendOp\": \"add\",\n" + " \"blendOpAlpha\": \"add\",\n" + " \"colorWriteMask\": 15\n" + " }\n" + "}\n"; + output.close(); + if (!output.good()) { + return nullptr; + } + + projectManager.RefreshCurrentFolder(); + const std::string createdMaterialPath = Platform::WideToUtf8(materialPath.wstring()); + const int createdIndex = projectManager.FindCurrentItemIndex(createdMaterialPath); + if (createdIndex < 0) { + return nullptr; + } + + const auto& items = projectManager.GetCurrentItems(); + if (createdIndex >= static_cast(items.size())) { + return nullptr; + } + + const AssetItemPtr createdMaterial = items[createdIndex]; + if (createdMaterial) { + projectManager.SetSelectedItem(createdMaterial); + } + return createdMaterial; + } catch (...) { + return nullptr; + } +} + inline bool DeleteAsset(IProjectManager& projectManager, const std::string& fullPath) { if (fullPath.empty()) { return false; @@ -204,6 +297,10 @@ inline bool SaveProjectDescriptor(IEditorContext& context) { } inline bool SaveProject(IEditorContext& context) { + if (!IsProjectDocumentEditingAllowed(context)) { + return false; + } + if (!EnsureProjectStructure(context.GetProjectPath())) { return false; } @@ -218,6 +315,10 @@ inline bool SaveProject(IEditorContext& context) { } inline bool SwitchProject(IEditorContext& context, const std::string& projectPath) { + if (!IsProjectDocumentEditingAllowed(context)) { + return false; + } + if (projectPath.empty()) { return false; } @@ -249,6 +350,10 @@ inline bool SwitchProject(IEditorContext& context, const std::string& projectPat } inline bool NewProjectWithDialog(IEditorContext& context) { + if (!IsProjectDocumentEditingAllowed(context)) { + return false; + } + const std::string projectPath = FileDialogUtils::PickFolderDialog( L"Select New Project Folder", context.GetProjectPath()); @@ -260,6 +365,10 @@ inline bool NewProjectWithDialog(IEditorContext& context) { } inline bool OpenProjectWithDialog(IEditorContext& context) { + if (!IsProjectDocumentEditingAllowed(context)) { + return false; + } + const std::string projectPath = FileDialogUtils::PickFolderDialog( L"Open Project Folder", context.GetProjectPath()); diff --git a/editor/src/Commands/SceneCommands.h b/editor/src/Commands/SceneCommands.h index c9b6783d..47594168 100644 --- a/editor/src/Commands/SceneCommands.h +++ b/editor/src/Commands/SceneCommands.h @@ -13,12 +13,20 @@ namespace XCEngine { namespace Editor { namespace Commands { +inline bool IsSceneDocumentEditingAllowed(const IEditorContext& context) { + return IsEditorDocumentEditingAllowed(context.GetRuntimeMode()); +} + inline void ResetSceneEditingState(IEditorContext& context) { context.GetSelectionManager().ClearSelection(); context.GetUndoManager().ClearHistory(); } inline bool NewScene(IEditorContext& context, const std::string& sceneName = "Untitled Scene") { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + if (!SceneEditorUtils::ConfirmSceneSwitch(context)) { return false; } @@ -29,6 +37,10 @@ inline bool NewScene(IEditorContext& context, const std::string& sceneName = "Un } inline bool LoadScene(IEditorContext& context, const std::string& filePath, bool confirmSwitch = true) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + if (filePath.empty()) { return false; } @@ -44,6 +56,10 @@ inline bool LoadScene(IEditorContext& context, const std::string& filePath, bool } inline bool OpenSceneWithDialog(IEditorContext& context) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + if (!SceneEditorUtils::ConfirmSceneSwitch(context)) { return false; } @@ -59,10 +75,18 @@ inline bool OpenSceneWithDialog(IEditorContext& context) { } inline bool SaveCurrentScene(IEditorContext& context) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + return SceneEditorUtils::SaveCurrentScene(context); } inline bool SaveSceneAsWithDialog(IEditorContext& context) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + auto& sceneManager = context.GetSceneManager(); const std::string filePath = SceneEditorUtils::SaveSceneFileDialog( context.GetProjectPath(), @@ -80,6 +104,10 @@ inline bool SaveSceneAsWithDialog(IEditorContext& context) { } inline bool LoadStartupScene(IEditorContext& context) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + const bool loaded = context.GetSceneManager().LoadStartupScene(context.GetProjectPath()); context.GetProjectManager().RefreshCurrentFolder(); ResetSceneEditingState(context); @@ -87,6 +115,10 @@ inline bool LoadStartupScene(IEditorContext& context) { } inline bool SaveDirtySceneWithFallback(IEditorContext& context, const std::string& fallbackPath) { + if (!IsSceneDocumentEditingAllowed(context)) { + return false; + } + auto& sceneManager = context.GetSceneManager(); if (!sceneManager.HasActiveScene() || !sceneManager.IsSceneDirty()) { return true; diff --git a/editor/src/Core/EditorContext.h b/editor/src/Core/EditorContext.h index d8134476..3f71379b 100644 --- a/editor/src/Core/EditorContext.h +++ b/editor/src/Core/EditorContext.h @@ -72,6 +72,20 @@ public: EditorActionRoute GetActiveActionRoute() const override { return m_activeActionRoute; } + + void SetRuntimeMode(EditorRuntimeMode mode) override { + if (m_runtimeMode == mode) { + return; + } + + const EditorRuntimeMode oldMode = m_runtimeMode; + m_runtimeMode = mode; + m_eventBus->Publish(EditorModeChangedEvent{ oldMode, m_runtimeMode }); + } + + EditorRuntimeMode GetRuntimeMode() const override { + return m_runtimeMode; + } void SetProjectPath(const std::string& path) override { m_projectPath = path; @@ -89,6 +103,7 @@ private: std::unique_ptr m_projectManager; IViewportHostService* m_viewportHostService = nullptr; EditorActionRoute m_activeActionRoute = EditorActionRoute::None; + EditorRuntimeMode m_runtimeMode = EditorRuntimeMode::Edit; std::string m_projectPath; uint64_t m_entityDeletedHandlerId; }; diff --git a/editor/src/Core/EditorEvents.h b/editor/src/Core/EditorEvents.h index a7055f1c..3d959e7d 100644 --- a/editor/src/Core/EditorEvents.h +++ b/editor/src/Core/EditorEvents.h @@ -1,5 +1,7 @@ #pragma once +#include "EditorRuntimeMode.h" + #include #include @@ -41,15 +43,24 @@ struct SceneChangedEvent { struct PlayModeStartedEvent { }; +struct PlayModeStartRequestedEvent { +}; + struct PlayModeStoppedEvent { }; +struct PlayModeStopRequestedEvent { +}; + struct PlayModePausedEvent { }; +struct PlayModePauseRequestedEvent { +}; + struct EditorModeChangedEvent { - int oldMode; - int newMode; + EditorRuntimeMode oldMode = EditorRuntimeMode::Edit; + EditorRuntimeMode newMode = EditorRuntimeMode::Edit; }; struct DockLayoutResetRequestedEvent { diff --git a/editor/src/Core/EditorRuntimeMode.h b/editor/src/Core/EditorRuntimeMode.h new file mode 100644 index 00000000..14118a96 --- /dev/null +++ b/editor/src/Core/EditorRuntimeMode.h @@ -0,0 +1,38 @@ +#pragma once + +namespace XCEngine { +namespace Editor { + +enum class EditorRuntimeMode { + Edit = 0, + Play, + Paused, + Simulate +}; + +inline bool IsEditorRuntimeActive(EditorRuntimeMode mode) { + return mode != EditorRuntimeMode::Edit; +} + +inline bool IsEditorDocumentEditingAllowed(EditorRuntimeMode mode) { + return mode == EditorRuntimeMode::Edit; +} + +inline bool IsEditorSceneObjectEditingAllowed(EditorRuntimeMode mode) { + switch (mode) { + case EditorRuntimeMode::Edit: + case EditorRuntimeMode::Play: + case EditorRuntimeMode::Paused: + case EditorRuntimeMode::Simulate: + return true; + default: + return false; + } +} + +inline bool IsEditorSceneUndoRedoAllowed(EditorRuntimeMode mode) { + return IsEditorSceneObjectEditingAllowed(mode); +} + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Core/EditorWindowTitle.h b/editor/src/Core/EditorWindowTitle.h index 96c7c2ca..25883b54 100644 --- a/editor/src/Core/EditorWindowTitle.h +++ b/editor/src/Core/EditorWindowTitle.h @@ -11,6 +11,22 @@ namespace Editor { inline std::string BuildEditorWindowTitle(IEditorContext& context) { auto& sceneManager = context.GetSceneManager(); + std::string modePrefix; + switch (context.GetRuntimeMode()) { + case EditorRuntimeMode::Play: + modePrefix = "[Play] "; + break; + case EditorRuntimeMode::Paused: + modePrefix = "[Paused] "; + break; + case EditorRuntimeMode::Simulate: + modePrefix = "[Simulate] "; + break; + case EditorRuntimeMode::Edit: + default: + break; + } + std::string sceneName = sceneManager.HasActiveScene() ? sceneManager.GetCurrentSceneName() : "No Scene"; if (sceneName.empty()) { sceneName = "Untitled Scene"; @@ -26,7 +42,7 @@ inline std::string BuildEditorWindowTitle(IEditorContext& context) { sceneName += std::filesystem::path(sceneManager.GetCurrentScenePath()).filename().string(); } - return sceneName + " - XCEngine Editor"; + return modePrefix + sceneName + " - XCEngine Editor"; } } // namespace Editor diff --git a/editor/src/Core/EditorWorkspace.h b/editor/src/Core/EditorWorkspace.h index 95b245dc..31d75cc9 100644 --- a/editor/src/Core/EditorWorkspace.h +++ b/editor/src/Core/EditorWorkspace.h @@ -2,6 +2,7 @@ #include "Commands/SceneCommands.h" #include "Core/IEditorContext.h" +#include "Core/PlaySessionController.h" #include "Layout/DockLayoutController.h" #include "panels/ConsolePanel.h" #include "panels/GameViewPanel.h" @@ -37,11 +38,13 @@ public: m_projectPanel->Initialize(context.GetProjectPath()); Commands::LoadStartupScene(context); + m_playSessionController.Attach(context); m_dockLayoutController->Attach(context); m_panels.AttachAll(); } void Detach(IEditorContext& context) { + m_playSessionController.Detach(context); Commands::SaveDirtySceneWithFallback(context, BuildFallbackScenePath(context)); if (m_dockLayoutController) { @@ -56,6 +59,9 @@ public: void Update(float dt) { ::XCEngine::Resources::ResourceManager::Get().UpdateAsyncLoads(); + if (IEditorContext* context = m_panels.GetContext()) { + m_playSessionController.Update(*context, dt); + } m_panels.UpdateAll(dt); } @@ -79,6 +85,7 @@ private: PanelCollection m_panels; ProjectPanel* m_projectPanel = nullptr; std::unique_ptr m_dockLayoutController; + PlaySessionController m_playSessionController; }; } // namespace Editor diff --git a/editor/src/Core/IEditorContext.h b/editor/src/Core/IEditorContext.h index 46a34ba8..a99b9cf2 100644 --- a/editor/src/Core/IEditorContext.h +++ b/editor/src/Core/IEditorContext.h @@ -1,6 +1,7 @@ #pragma once #include "EditorActionRoute.h" +#include "EditorRuntimeMode.h" #include #include @@ -27,6 +28,8 @@ public: virtual IViewportHostService* GetViewportHostService() = 0; virtual void SetActiveActionRoute(EditorActionRoute route) = 0; virtual EditorActionRoute GetActiveActionRoute() const = 0; + virtual void SetRuntimeMode(EditorRuntimeMode mode) = 0; + virtual EditorRuntimeMode GetRuntimeMode() const = 0; virtual void SetProjectPath(const std::string& path) = 0; virtual const std::string& GetProjectPath() const = 0; diff --git a/editor/src/Core/ISceneManager.h b/editor/src/Core/ISceneManager.h index 4977479e..f51cb374 100644 --- a/editor/src/Core/ISceneManager.h +++ b/editor/src/Core/ISceneManager.h @@ -1,5 +1,7 @@ #pragma once +#include "SceneSnapshot.h" + #include #include #include @@ -34,10 +36,14 @@ public: virtual bool HasActiveScene() const = 0; virtual bool IsSceneDirty() const = 0; virtual void MarkSceneDirty() = 0; + virtual void SetSceneDocumentDirtyTrackingEnabled(bool enabled) = 0; + virtual bool IsSceneDocumentDirtyTrackingEnabled() const = 0; virtual const std::string& GetCurrentScenePath() const = 0; virtual const std::string& GetCurrentSceneName() const = 0; virtual ::XCEngine::Components::Scene* GetScene() = 0; virtual const ::XCEngine::Components::Scene* GetScene() const = 0; + virtual SceneSnapshot CaptureSceneSnapshot() const = 0; + virtual bool RestoreSceneSnapshot(const SceneSnapshot& snapshot) = 0; virtual void CreateDemoScene() = 0; }; diff --git a/editor/src/Core/PlaySessionController.cpp b/editor/src/Core/PlaySessionController.cpp new file mode 100644 index 00000000..5ee9521e --- /dev/null +++ b/editor/src/Core/PlaySessionController.cpp @@ -0,0 +1,122 @@ +#include "Core/PlaySessionController.h" + +#include "Core/EditorEvents.h" +#include "Core/EventBus.h" +#include "Core/IEditorContext.h" +#include "Core/ISceneManager.h" +#include "Core/IUndoManager.h" + +namespace XCEngine { +namespace Editor { + +void PlaySessionController::Attach(IEditorContext& context) { + if (m_playStartRequestedHandlerId == 0) { + m_playStartRequestedHandlerId = context.GetEventBus().Subscribe( + [this, &context](const PlayModeStartRequestedEvent&) { + StartPlay(context); + }); + } + + if (m_playStopRequestedHandlerId == 0) { + m_playStopRequestedHandlerId = context.GetEventBus().Subscribe( + [this, &context](const PlayModeStopRequestedEvent&) { + StopPlay(context); + }); + } + + if (m_playPauseRequestedHandlerId == 0) { + m_playPauseRequestedHandlerId = context.GetEventBus().Subscribe( + [this, &context](const PlayModePauseRequestedEvent&) { + PausePlay(context); + }); + } +} + +void PlaySessionController::Detach(IEditorContext& context) { + StopPlay(context); + + if (m_playStartRequestedHandlerId != 0) { + context.GetEventBus().Unsubscribe(m_playStartRequestedHandlerId); + m_playStartRequestedHandlerId = 0; + } + + if (m_playStopRequestedHandlerId != 0) { + context.GetEventBus().Unsubscribe(m_playStopRequestedHandlerId); + m_playStopRequestedHandlerId = 0; + } + + if (m_playPauseRequestedHandlerId != 0) { + context.GetEventBus().Unsubscribe(m_playPauseRequestedHandlerId); + m_playPauseRequestedHandlerId = 0; + } +} + +void PlaySessionController::Update(IEditorContext& context, float deltaTime) { + (void)context; + if (!m_runtimeLoop.IsRunning()) { + return; + } + + m_runtimeLoop.Tick(deltaTime); +} + +bool PlaySessionController::StartPlay(IEditorContext& context) { + if (context.GetRuntimeMode() != EditorRuntimeMode::Edit) { + return false; + } + + auto& sceneManager = context.GetSceneManager(); + if (!sceneManager.HasActiveScene()) { + return false; + } + + m_editorSnapshot = sceneManager.CaptureSceneSnapshot(); + if (!m_editorSnapshot.hasScene) { + return false; + } + + if (!sceneManager.RestoreSceneSnapshot(m_editorSnapshot)) { + return false; + } + + sceneManager.SetSceneDocumentDirtyTrackingEnabled(false); + m_runtimeLoop.Start(sceneManager.GetScene()); + context.GetUndoManager().ClearHistory(); + context.SetRuntimeMode(EditorRuntimeMode::Play); + context.GetEventBus().Publish(PlayModeStartedEvent{}); + return true; +} + +bool PlaySessionController::StopPlay(IEditorContext& context) { + if (!IsEditorRuntimeActive(context.GetRuntimeMode())) { + return false; + } + + auto& sceneManager = context.GetSceneManager(); + m_runtimeLoop.Stop(); + sceneManager.SetSceneDocumentDirtyTrackingEnabled(true); + + if (!sceneManager.RestoreSceneSnapshot(m_editorSnapshot)) { + return false; + } + + context.GetUndoManager().ClearHistory(); + context.SetRuntimeMode(EditorRuntimeMode::Edit); + context.GetEventBus().Publish(PlayModeStoppedEvent{}); + m_editorSnapshot = {}; + return true; +} + +bool PlaySessionController::PausePlay(IEditorContext& context) { + if (context.GetRuntimeMode() != EditorRuntimeMode::Play || !m_runtimeLoop.IsRunning()) { + return false; + } + + m_runtimeLoop.Pause(); + context.SetRuntimeMode(EditorRuntimeMode::Paused); + context.GetEventBus().Publish(PlayModePausedEvent{}); + return true; +} + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Core/PlaySessionController.h b/editor/src/Core/PlaySessionController.h new file mode 100644 index 00000000..87779ebf --- /dev/null +++ b/editor/src/Core/PlaySessionController.h @@ -0,0 +1,35 @@ +#pragma once + +#include "EditorRuntimeMode.h" +#include "SceneSnapshot.h" + +#include + +#include + +namespace XCEngine { +namespace Editor { + +class IEditorContext; + +class PlaySessionController { +public: + void Attach(IEditorContext& context); + void Detach(IEditorContext& context); + + void Update(IEditorContext& context, float deltaTime); + + bool StartPlay(IEditorContext& context); + bool StopPlay(IEditorContext& context); + bool PausePlay(IEditorContext& context); + +private: + uint64_t m_playStartRequestedHandlerId = 0; + uint64_t m_playStopRequestedHandlerId = 0; + uint64_t m_playPauseRequestedHandlerId = 0; + SceneSnapshot m_editorSnapshot = {}; + XCEngine::Components::RuntimeLoop m_runtimeLoop; +}; + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Managers/SceneManager.cpp b/editor/src/Managers/SceneManager.cpp index 6c64b3f7..0b9d3e84 100644 --- a/editor/src/Managers/SceneManager.cpp +++ b/editor/src/Managers/SceneManager.cpp @@ -32,6 +32,10 @@ void SceneManager::SetSceneDirty(bool dirty) { } void SceneManager::MarkSceneDirty() { + if (!m_sceneDocumentDirtyTrackingEnabled) { + return; + } + SetSceneDirty(true); } @@ -43,7 +47,7 @@ void SceneManager::MarkSceneDirty() { ::XCEngine::Components::GameObject* entity = m_scene->CreateGameObject(name, parent); const auto entityId = entity->GetID(); SyncRootEntities(); - SetSceneDirty(true); + MarkSceneDirty(); OnEntityCreated.Invoke(entityId); OnSceneChanged.Invoke(); @@ -70,7 +74,7 @@ void SceneManager::DeleteEntity(::XCEngine::Components::GameObject::ID id) { const auto entityId = entity->GetID(); m_scene->DestroyGameObject(entity); SyncRootEntities(); - SetSceneDirty(true); + MarkSceneDirty(); OnEntityDeleted.Invoke(entityId); OnSceneChanged.Invoke(); @@ -150,7 +154,7 @@ void SceneManager::CopyEntity(::XCEngine::Components::GameObject::ID id) { const auto newEntityId = PasteEntityRecursive(*m_clipboard, parent); SyncRootEntities(); - SetSceneDirty(true); + MarkSceneDirty(); OnEntityCreated.Invoke(newEntityId); OnSceneChanged.Invoke(); @@ -320,7 +324,7 @@ void SceneManager::RenameEntity(::XCEngine::Components::GameObject::ID id, const if (!obj) return; obj->SetName(newName); - SetSceneDirty(true); + MarkSceneDirty(); OnEntityChanged.Invoke(id); if (m_eventBus) { @@ -342,7 +346,7 @@ void SceneManager::MoveEntity(::XCEngine::Components::GameObject::ID id, ::XCEng obj->SetParent(newParent); SyncRootEntities(); - SetSceneDirty(true); + MarkSceneDirty(); OnEntityChanged.Invoke(id); OnSceneChanged.Invoke(); diff --git a/editor/src/Managers/SceneManager.h b/editor/src/Managers/SceneManager.h index e6a8912c..4b1fa359 100644 --- a/editor/src/Managers/SceneManager.h +++ b/editor/src/Managers/SceneManager.h @@ -57,15 +57,21 @@ public: bool HasActiveScene() const override { return m_scene != nullptr; } bool IsSceneDirty() const override { return m_isSceneDirty; } void MarkSceneDirty() override; + void SetSceneDocumentDirtyTrackingEnabled(bool enabled) override { + m_sceneDocumentDirtyTrackingEnabled = enabled; + } + bool IsSceneDocumentDirtyTrackingEnabled() const override { + return m_sceneDocumentDirtyTrackingEnabled; + } const std::string& GetCurrentScenePath() const override { return m_currentScenePath; } const std::string& GetCurrentSceneName() const override { return m_currentSceneName; } ::XCEngine::Components::Scene* GetScene() override { return m_scene.get(); } const ::XCEngine::Components::Scene* GetScene() const override { return m_scene.get(); } + SceneSnapshot CaptureSceneSnapshot() const override; + bool RestoreSceneSnapshot(const SceneSnapshot& snapshot) override; void CreateDemoScene() override; bool HasClipboardData() const { return m_clipboard.has_value(); } - SceneSnapshot CaptureSceneSnapshot() const; - bool RestoreSceneSnapshot(const SceneSnapshot& snapshot); ::XCEngine::Core::Event<::XCEngine::Components::GameObject::ID> OnEntityCreated; ::XCEngine::Core::Event<::XCEngine::Components::GameObject::ID> OnEntityDeleted; @@ -97,6 +103,7 @@ private: std::string m_currentScenePath; std::string m_currentSceneName = "Untitled Scene"; bool m_isSceneDirty = false; + bool m_sceneDocumentDirtyTrackingEnabled = true; }; } diff --git a/editor/src/panels/ConsolePanel.cpp b/editor/src/panels/ConsolePanel.cpp index 7663ee9b..ce3c2bee 100644 --- a/editor/src/panels/ConsolePanel.cpp +++ b/editor/src/panels/ConsolePanel.cpp @@ -511,27 +511,27 @@ bool DrawCompactCheckedMenuItem(const char* label, bool checked) { return false; } - const bool clicked = ImGui::Selectable(label, false, ImGuiSelectableFlags_SpanAvailWidth); + const bool clicked = ImGui::MenuItem(label, nullptr, false, true); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); if (checked) { - const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); ImDrawList* drawList = ImGui::GetWindowDrawList(); const ImU32 color = ImGui::GetColorU32(ImGuiCol_CheckMark); const float height = rect.Max.y - rect.Min.y; - const float checkWidth = height * 0.28f; - const float checkHeight = height * 0.18f; - const float x = rect.Max.x - 12.0f; + const float checkWidth = height * 0.24f; + const float checkHeight = height * 0.16f; + const float x = rect.Max.x - 10.0f; const float y = rect.Min.y + height * 0.52f; drawList->AddLine( ImVec2(x - checkWidth, y - checkHeight * 0.15f), ImVec2(x - checkWidth * 0.42f, y + checkHeight), color, - 1.4f); + 1.8f); drawList->AddLine( ImVec2(x - checkWidth * 0.42f, y + checkHeight), ImVec2(x + checkWidth, y - checkHeight), color, - 1.4f); + 1.8f); } return clicked; @@ -973,7 +973,7 @@ void ConsolePanel::Render() { } if (shouldPause) { - m_context->GetEventBus().Publish(PlayModePausedEvent{}); + m_context->GetEventBus().Publish(PlayModePauseRequestedEvent{}); m_playModePaused = true; } m_lastErrorPauseScanSerial = latestSerial; diff --git a/editor/src/panels/PanelCollection.h b/editor/src/panels/PanelCollection.h index 8173a1fc..b611eedd 100644 --- a/editor/src/panels/PanelCollection.h +++ b/editor/src/panels/PanelCollection.h @@ -14,6 +14,10 @@ class IEditorContext; class PanelCollection { public: + IEditorContext* GetContext() const { + return m_context; + } + void SetContext(IEditorContext* context) { m_context = context; diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index c619694c..f3552228 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -377,9 +377,11 @@ add_library(XCEngine STATIC # Scene ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/Scene.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/SceneRuntime.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/RuntimeLoop.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/SceneManager.h ${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/Scene.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/SceneRuntime.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/RuntimeLoop.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/SceneManager.cpp # Platform diff --git a/engine/include/XCEngine/Scene/RuntimeLoop.h b/engine/include/XCEngine/Scene/RuntimeLoop.h new file mode 100644 index 00000000..6f8fd387 --- /dev/null +++ b/engine/include/XCEngine/Scene/RuntimeLoop.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include + +namespace XCEngine { +namespace Components { + +class RuntimeLoop { +public: + struct Settings { + float fixedDeltaTime = 1.0f / 50.0f; + float maxFrameDeltaTime = 0.1f; + uint32_t maxFixedStepsPerFrame = 4; + }; + + explicit RuntimeLoop(Settings settings = {}); + + void SetSettings(const Settings& settings); + const Settings& GetSettings() const { return m_settings; } + + void Start(Scene* scene); + void Stop(); + + void Tick(float deltaTime); + void Pause(); + void Resume(); + void StepFrame(); + + bool IsRunning() const { return m_sceneRuntime.IsRunning(); } + bool IsPaused() const { return m_paused; } + Scene* GetScene() const { return m_sceneRuntime.GetScene(); } + float GetFixedAccumulator() const { return m_fixedAccumulator; } + +private: + SceneRuntime m_sceneRuntime; + Settings m_settings = {}; + float m_fixedAccumulator = 0.0f; + bool m_paused = false; + bool m_stepRequested = false; +}; + +} // namespace Components +} // namespace XCEngine diff --git a/engine/src/Scene/RuntimeLoop.cpp b/engine/src/Scene/RuntimeLoop.cpp new file mode 100644 index 00000000..95ca960a --- /dev/null +++ b/engine/src/Scene/RuntimeLoop.cpp @@ -0,0 +1,100 @@ +#include "Scene/RuntimeLoop.h" + +#include + +namespace XCEngine { +namespace Components { + +namespace { + +RuntimeLoop::Settings SanitizeSettings(RuntimeLoop::Settings settings) { + if (settings.fixedDeltaTime <= 0.0f) { + settings.fixedDeltaTime = 1.0f / 50.0f; + } + if (settings.maxFrameDeltaTime < 0.0f) { + settings.maxFrameDeltaTime = 0.0f; + } + if (settings.maxFixedStepsPerFrame == 0) { + settings.maxFixedStepsPerFrame = 1; + } + return settings; +} + +} // namespace + +RuntimeLoop::RuntimeLoop(Settings settings) + : m_settings(SanitizeSettings(settings)) { +} + +void RuntimeLoop::SetSettings(const Settings& settings) { + m_settings = SanitizeSettings(settings); +} + +void RuntimeLoop::Start(Scene* scene) { + m_fixedAccumulator = 0.0f; + m_paused = false; + m_stepRequested = false; + m_sceneRuntime.Start(scene); +} + +void RuntimeLoop::Stop() { + m_sceneRuntime.Stop(); + m_fixedAccumulator = 0.0f; + m_paused = false; + m_stepRequested = false; +} + +void RuntimeLoop::Tick(float deltaTime) { + if (!IsRunning()) { + return; + } + + if (m_paused && !m_stepRequested) { + return; + } + + const float clampedDeltaTime = std::clamp(deltaTime, 0.0f, m_settings.maxFrameDeltaTime); + m_fixedAccumulator += clampedDeltaTime; + + uint32_t fixedStepsExecuted = 0; + while (m_fixedAccumulator >= m_settings.fixedDeltaTime && + fixedStepsExecuted < m_settings.maxFixedStepsPerFrame) { + m_sceneRuntime.FixedUpdate(m_settings.fixedDeltaTime); + m_fixedAccumulator -= m_settings.fixedDeltaTime; + ++fixedStepsExecuted; + } + + m_sceneRuntime.Update(clampedDeltaTime); + m_sceneRuntime.LateUpdate(clampedDeltaTime); + m_stepRequested = false; +} + +void RuntimeLoop::Pause() { + if (!IsRunning()) { + return; + } + + m_paused = true; + m_stepRequested = false; +} + +void RuntimeLoop::Resume() { + if (!IsRunning()) { + return; + } + + m_paused = false; + m_stepRequested = false; +} + +void RuntimeLoop::StepFrame() { + if (!IsRunning()) { + return; + } + + m_paused = true; + m_stepRequested = true; +} + +} // namespace Components +} // namespace XCEngine diff --git a/tests/Scene/CMakeLists.txt b/tests/Scene/CMakeLists.txt index 679eadaa..c5bfb3cc 100644 --- a/tests/Scene/CMakeLists.txt +++ b/tests/Scene/CMakeLists.txt @@ -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 ) diff --git a/tests/Scene/test_runtime_loop.cpp b/tests/Scene/test_runtime_loop.cpp new file mode 100644 index 00000000..4085cccf --- /dev/null +++ b/tests/Scene/test_runtime_loop.cpp @@ -0,0 +1,134 @@ +#include + +#include +#include +#include +#include + +#include +#include + +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(name); + return m_scene.get(); + } + + RuntimeLoopCounters counters; + std::unique_ptr m_scene; +}; + +TEST_F(RuntimeLoopTest, AccumulatesFixedUpdatesAcrossFramesAndRunsVariableUpdatesEveryTick) { + RuntimeLoop loop({0.02f, 0.1f, 4}); + Scene* scene = CreateScene(); + GameObject* host = scene->CreateGameObject("Host"); + host->AddComponent(&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(&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(&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 diff --git a/tests/editor/CMakeLists.txt b/tests/editor/CMakeLists.txt index c2288ff8..dc9ef0f3 100644 --- a/tests/editor/CMakeLists.txt +++ b/tests/editor/CMakeLists.txt @@ -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 diff --git a/tests/editor/test_action_routing.cpp b/tests/editor/test_action_routing.cpp index 0812326d..12545e4b 100644 --- a/tests/editor/test_action_routing.cpp +++ b/tests/editor/test_action_routing.cpp @@ -7,6 +7,7 @@ #include "Commands/EntityCommands.h" #include "Commands/SceneCommands.h" #include "Core/EditorContext.h" +#include "Core/PlaySessionController.h" #include #include @@ -283,6 +284,45 @@ TEST_F(EditorActionRoutingTest, MainMenuRouterRequestsExitResetAndAboutPopup) { m_context.GetEventBus().Unsubscribe(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); diff --git a/tests/editor/test_play_session_controller.cpp b/tests/editor/test_play_session_controller.cpp new file mode 100644 index 00000000..b5f828b7 --- /dev/null +++ b/tests/editor/test_play_session_controller.cpp @@ -0,0 +1,83 @@ +#include + +#include "Core/EditorContext.h" +#include "Core/EditorEvents.h" +#include "Core/PlaySessionController.h" + +#include + +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( + [&](const PlayModeStartedEvent&) { + ++startedCount; + }); + const uint64_t stoppedSubscription = m_context.GetEventBus().Subscribe( + [&](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(startedSubscription); + m_context.GetEventBus().Unsubscribe(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