# 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 时托管后者,但两者绝不能被当成同一个东西。**