Files
XCEngine/docs/used/Unity式Tick系统与PlayMode运行时方案.md

1055 lines
26 KiB
Markdown
Raw Permalink Normal View History

# 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
#### 缺失 2runtime 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
#### 缺失 4Play/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<Components::Scene> 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 时托管后者,但两者绝不能被当成同一个东西。**