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

26 KiB
Raw Blame History

Unity 式 Tick 系统与 Play Mode 运行时方案

日期4.2

1. 背景与问题定义

当前仓库已经具备一批运行时生命周期原语:

  • Application 已经能稳定提供 editor 每帧的 deltaTime
  • SceneRuntime 已经具备 Start / Stop / FixedUpdate / Update / LateUpdate
  • SceneGameObject 已经能向原生组件继续分发生命周期
  • 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 tickengine 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 APIfixedDeltaTimetimeScaleunscaledDeltaTime
  • 全量输入系统 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/Sceneengine/Runtime 相关目录下。

它负责纯运行时行为,不依赖任何 editor 类型。

职责

  • 持有 SceneRuntime
  • 持有 running / paused 状态
  • 持有 fixed timestep accumulator
  • 接受宿主传入的 dt
  • 统一调度 FixedUpdate / Update / LateUpdate
  • 提供 Tick(dt)Pause()Resume()StepOnce()

不负责

  • 场景文件保存
  • editor Undo/Dirty
  • 面板 UI
  • GameView 工具栏状态
  • 项目切换

建议接口

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 模式至少定义如下枚举:

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. 运行帧尾系统刷新

伪代码如下:

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 建议字段

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 建议字段

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