Files
XCEngine/docs/plan/C#脚本模块的设计与实现.md

18 KiB
Raw Blame History

C#脚本模块的设计与实现

1. 背景

XCEngine 的整体方向是模仿传统 Unity 引擎架构,而不是 DOTS/ECS-first 路线。 在这一目标下,脚本系统应当满足以下基本预期:

  • 脚本语言使用 C#
  • 脚本以“挂载到 GameObject 上的组件”形式工作
  • 脚本与引擎核心解耦,支持独立编译和运行时加载
  • 脚本可以逐步扩展到 Inspector、场景序列化、Play/Simulate 工作流

当前 editor 仍处于基础阶段,因此脚本系统第一阶段不应依赖 editor 完整落地。 第一阶段的目标应当收敛为:

  • 先完成原生运行时与托管运行时之间的桥接
  • 先完成 ScriptComponent + C# MonoBehaviour 的基本执行链路
  • 先完成单元测试和最小场景级验证
  • 将 editor 集成需求单独列为 issue后续补齐

2. 设计目标

2.1 总体目标

脚本系统应当提供一条接近 Unity 的开发路径:

  1. 用户在独立的 C# 项目中编写脚本
  2. C# 脚本编译为程序集
  3. Engine Core 在运行时加载程序集
  4. GameObject 上挂载 ScriptComponent
  5. ScriptComponent 驱动一个对应的 C# MonoBehaviour 实例
  6. 脚本通过引擎暴露的 API 调用原生功能

2.2 第一阶段目标

第一阶段只覆盖以下内容:

  • engine 内部的脚本运行时抽象
  • 第一套 C# 运行时实现
  • ScriptComponent 原生组件
  • ScriptCore 托管基础库
  • 最小可用的 InternalCall 绑定
  • 单元测试与最小运行时测试

2.3 第一阶段非目标

第一阶段明确不做以下内容:

  • editor Inspector 脚本字段编辑
  • editor 中的脚本类选择器
  • editor 的 Play/Simulate 集成
  • 自动编译、文件监听、热重载
  • 调试器接入
  • 发布态 AOT / IL2CPP
  • 大而全的引擎 API 暴露

3. 三方对比结论

3.1 Unity

Unity 的典型脚本模型有几个关键特征:

  • 用户脚本通常继承 MonoBehaviour
  • 一段脚本本质上就是一个组件实例
  • 脚本字段可序列化、可在 Inspector 中编辑
  • 生命周期完整,且与 GameObject active / Component enabled 语义一致
  • Play 模式运行的是运行时场景副本,而不是直接修改编辑场景

3.2 参考项目 Fermion

参考项目已经实现了一套 C# 脚本模块,优点主要在于:

  • 已经证明了 Mono embedding + InternalCall + C# 程序集加载 这条路线可行
  • 已经具备脚本类发现、脚本实例创建、字段反射、运行时调用
  • 已经有托管侧 API 包装层和原生侧 ScriptGlue

但它的对象模型并不是 Unity 风格:

  • 托管脚本类继承的是 Entity
  • 原生挂载结构是 ScriptContainerComponent
  • 更像“一个实体挂多个脚本类名”,而不是“每个脚本本身就是一个组件”
  • 生命周期目前主要聚焦 OnCreate/OnUpdate

结论:

  • Fermion 适合借鉴运行时技术路线
  • Fermion 不适合作为最终 API 形态的直接模板

3.3 XCEngine 当前情况

XCEngine 当前已经具备以下基础:

  • 已有 GameObject + Component + Scene 模型
  • Component 已定义 Unity 风格生命周期接口
  • SceneGameObject 已有序列化入口
  • ComponentFactoryRegistry 已支持按类型名恢复组件

但当前也存在会直接影响脚本系统设计的现实约束:

  • Scene::Update/FixedUpdate/LateUpdate 目前只遍历根对象
  • GameObject::Update/FixedUpdate/LateUpdate 目前不递归子对象
  • Start 的场景级一次性调度路径还未完整建立
  • SetActive 尚未真正驱动 OnEnable/OnDisable
  • editor 的 Play/Simulate 工作流仍未落地
  • GameObject UUID 当前未进入场景序列化主路径

结论:

  • XCEngine 适合走 Unity 风格脚本模型
  • 但脚本系统第一阶段必须连同一部分运行时地基一起建设

4. 总体设计结论

XCEngine 的 C# 脚本模块采用以下路线:

  • 脚本语言C#
  • 脚本挂载模型:原生 ScriptComponent 对应托管 MonoBehaviour
  • 程序集模型ScriptCoreGameScripts 分离
  • 运行时加载模式:独立编译,运行时加载
  • 桥接方式InternalCall
  • 运行时抽象策略:先抽象接口,再优先落 Mono 实现
  • 第一阶段验证方式:单元测试优先,不依赖 editor

这条路线有两个核心原则:

  1. API 形态尽量接近 Unity
  2. 第一阶段严格控制范围,只做运行时闭环和测试闭环

5. 运行时选型

5.1 第一阶段选型Mono

第一阶段建议使用 Mono 作为第一套 C# 运行时实现,原因如下:

  • 与现有规划文档保持一致
  • 参考项目已经证明这条技术路线可落地
  • InternalCall 路线成熟,适合快速建立最小可用系统
  • 便于在 Windows 环境中先做出可运行结果

5.2 选型边界

本设计不把 Mono 写死为脚本系统唯一实现,而是将其作为第一实现:

  • 对外暴露 IScriptRuntime / ScriptEngine 抽象
  • MonoScriptRuntime 作为第一套后端
  • 后续如有需要,可以演进到 CoreCLR 或其他运行时

5.3 第一阶段不做的运行时能力

Mono 相关的以下复杂能力不进入第一阶段:

  • 域热重载
  • 编辑器内自动重编译后重载
  • 托管调试器接入
  • 发布态 AOT

第一阶段仅要求:

  • 初始化运行时
  • 加载核心程序集
  • 加载用户程序集
  • 发现脚本类
  • 实例化对象
  • 调用生命周期
  • 读写托管字段

6. 模块划分

6.1 原生侧模块

建议在 engine 内新增 Scripting 模块:

engine/
├── include/XCEngine/Scripting/
│   ├── ScriptEngine.h
│   ├── IScriptRuntime.h
│   ├── ScriptAssembly.h
│   ├── ScriptClass.h
│   ├── ScriptInstance.h
│   ├── ScriptField.h
│   ├── ScriptFieldStorage.h
│   ├── ScriptGlue.h
│   └── ScriptComponent.h
└── src/Scripting/
    ├── ScriptEngine.cpp
    ├── ScriptGlue.cpp
    ├── ScriptComponent.cpp
    └── Mono/
        ├── MonoScriptRuntime.cpp
        ├── MonoScriptClass.cpp
        └── MonoScriptAssembly.cpp

6.2 托管侧模块

建议新增托管核心库 ScriptCore

managed/
├── XCEngine.ScriptCore/
│   ├── XCEngine.ScriptCore.csproj
│   ├── Object.cs
│   ├── Component.cs
│   ├── Behaviour.cs
│   ├── MonoBehaviour.cs
│   ├── GameObject.cs
│   ├── Transform.cs
│   ├── Debug.cs
│   ├── Time.cs
│   └── InternalCalls.cs
└── GameScripts/
    ├── GameScripts.csproj
    └── Scripts/*.cs

6.3 程序集分层

程序集分为两层:

  • XCEngine.ScriptCore.dll
    • 由引擎维护
    • 提供托管基类和引擎 API 包装
  • GameScripts.dll
    • 由项目侧维护
    • 编写具体游戏脚本

关系如下:

GameScripts.dll
    └── 引用 XCEngine.ScriptCore.dll

Engine Core
    ├── 先加载 XCEngine.ScriptCore.dll
    └── 再加载 GameScripts.dll

7. 对象模型

7.1 托管侧模型

托管侧应当采用接近 Unity 的对象层次:

Object
└── Component
    └── Behaviour
        └── MonoBehaviour

其中:

  • Object:基础托管对象
  • Component:挂载到 GameObject 上的托管组件基类
  • Behaviour:带 enabled 语义的组件
  • MonoBehaviour:用户脚本直接继承的基类

7.2 原生挂载模型

原生侧脚本挂载使用 ScriptComponent,而不是 ScriptContainerComponent

原因如下:

  • 更符合 Unity 认知模型
  • 更容易与现有 GameObject::AddComponent<T> 思路对齐
  • 更容易在未来做 Inspector 级脚本组件显示
  • 更容易把字段序列化与组件实例对应起来

建议 ScriptComponent 至少包含:

  • scriptComponentUUID
  • assemblyName
  • namespaceName
  • className
  • enabled
  • fieldStorage

推荐接口示意:

class ScriptComponent : public Component {
public:
    std::string GetName() const override { return "Script"; }

    const std::string& GetAssemblyName() const;
    const std::string& GetNamespaceName() const;
    const std::string& GetClassName() const;
    std::string GetFullClassName() const;

    uint64_t GetScriptComponentUUID() const;
    bool IsRuntimeValid() const;

    void Serialize(std::ostream& os) const override;
    void Deserialize(std::istream& is) override;

private:
    uint64_t m_scriptComponentUUID = 0;
    std::string m_assemblyName = "GameScripts";
    std::string m_namespaceName;
    std::string m_className;
    ScriptFieldStorage m_fieldStorage;
};

7.3 多脚本挂载

一个 GameObject 应允许挂载多个 ScriptComponent

这与 Unity 保持一致:

  • 同一个对象可以挂多个不同脚本
  • 同一个脚本是否允许重复挂载,由后续属性或规则控制
  • 第一阶段不做“禁止重复挂载”的复杂策略

8. 身份模型与序列化要求

8.1 必须使用 UUID而不是运行时 ID

脚本系统中,托管实例与原生对象的稳定绑定必须建立在 UUID 之上,而不是当前的自增 ID

原因如下:

  • 运行时场景复制不能依赖自增 ID 稳定
  • 脚本字段里的对象引用必须有稳定键
  • editor 与 runtime 之间的对象映射必须有稳定键
  • 单元测试和场景恢复也需要稳定身份

因此需要补齐以下要求:

  • GameObject UUID 进入场景序列化
  • 场景反序列化时恢复 UUID
  • ScriptComponent 自身也应有持久化 UUID

8.2 第一阶段字段序列化策略

第一阶段的脚本字段序列化原则如下:

  • 只序列化 ScriptComponent 的字段缓存
  • 只序列化脚本作者显式设置的字段值
  • 运行时脚本执行过程中修改的值,不自动回写场景

这与 Unity 的 Play 模式行为一致:

  • Play 中的运行时改动不应直接污染编辑数据

8.3 第一阶段字段支持范围

建议第一阶段先支持:

  • float
  • double
  • bool
  • int32
  • uint64
  • string
  • Vector2
  • Vector3
  • Vector4
  • GameObject 引用

第一阶段不要求支持:

  • List<T>
  • 自定义托管结构体
  • 嵌套对象图
  • 泛型容器
  • 资源引用对象选择器

9. 生命周期设计

9.1 目标生命周期

脚本系统最终应支持以下 Unity 风格生命周期:

  • Awake
  • OnEnable
  • Start
  • FixedUpdate
  • Update
  • LateUpdate
  • OnDisable
  • OnDestroy

9.2 第一阶段生命周期闭环

第一阶段就应当把上述生命周期的原生调度链路设计好,哪怕 editor 尚未接入。

建议运行时流程如下:

Scene 运行时启动

  1. ScriptEngine::OnRuntimeStart(scene)
  2. 遍历场景中全部激活对象
  3. 找到所有 ScriptComponent
  4. 为每个组件创建托管 MonoBehaviour 实例
  5. 写入原生对象 UUID / 组件 UUID / 基础上下文
  6. 应用序列化字段缓存
  7. 调用 Awake
  8. 若对象激活且组件启用,调用 OnEnable
  9. 标记“等待 Start”

每帧执行

  • 物理阶段:FixedUpdate
  • 普通阶段:Update
  • 后处理阶段:LateUpdate
  • 对于尚未执行 Start 的脚本,在第一次普通帧前先调用 Start

运行时停止

  1. 对仍处于启用状态的脚本调用 OnDisable
  2. 对所有脚本调用 OnDestroy
  3. 清理托管实例表
  4. 清理运行时场景上下文

9.3 对现有引擎的前置要求

为了让脚本生命周期符合预期,现有引擎需要补齐以下地基:

  • Scene 更新必须递归整个层级,而不是只更新根对象
  • Start 必须具备“一次且仅一次”语义
  • SetActive 必须驱动 OnEnable/OnDisable
  • 运行时场景启动与停止必须显式化
  • 创建对象时不应直接把“编辑态创建”与“运行态 Awake”混为一谈

这些工作虽然不都属于脚本模块,但它们是脚本模块的直接运行前提。


10. 原生与托管之间的桥接

10.1 桥接方式

第一阶段使用 InternalCall

  • 托管侧通过 MethodImplOptions.InternalCall 声明方法
  • 原生侧通过 mono_add_internal_call 注册

10.2 第一阶段最小 API 集

第一阶段建议只暴露最小必需 API

  • Debug.Log / LogWarning / LogError
  • Time.deltaTime
  • GameObject.GetName / SetName
  • GameObject.GetTransform
  • Component.GetGameObject
  • GameObject.HasComponent<T>
  • GameObject.GetComponent<T>
  • Transform 的本地位置 / 旋转 / 缩放

这套 API 足够覆盖以下测试与最小演示:

  • 变换脚本
  • 旋转/移动脚本
  • 生命周期日志验证
  • 组件访问验证

第一阶段不建议优先暴露:

  • 物理 API
  • 渲染 API
  • 音频 API
  • 输入系统
  • 资源系统

原因很简单:

  • 第一阶段以单元测试闭环为主
  • 暴露面越大,绑定维护成本越高
  • 当前这些系统本身仍在演进

11. 类发现与实例管理

11.1 脚本类发现规则

用户脚本类应满足以下条件才被视为可挂载脚本:

  • 定义在 GameScripts.dll
  • 非抽象类
  • 继承 XCEngine.MonoBehaviour

11.2 缓存结构

原生运行时需要缓存以下信息:

  • 程序集表
  • 脚本类表
  • 方法句柄表
  • 字段元数据表
  • 运行时实例表

建议实例表键使用:

  • GameObjectUUID + ScriptComponentUUID

而不是:

  • 内存地址
  • 组件在容器中的索引
  • 自增 ID

11.3 方法缓存

每个脚本类应缓存常用生命周期方法句柄:

  • Awake
  • OnEnable
  • Start
  • FixedUpdate
  • Update
  • LateUpdate
  • OnDisable
  • OnDestroy

这样可以避免每帧按字符串查找方法。


12. 单元测试优先策略

12.1 原则

第一阶段脚本模块不依赖 editor因此验证策略以单元测试和最小运行时测试为主。

12.2 测试目录建议

tests/
└── Scripting/
    ├── unit/
    │   ├── test_script_runtime.cpp
    │   ├── test_script_metadata.cpp
    │   ├── test_script_component.cpp
    │   ├── test_script_fields.cpp
    │   └── CMakeLists.txt
    └── managed/
        ├── XCEngine.ScriptCore/
        └── TestScripts/

12.3 第一阶段必须覆盖的测试

运行时初始化

  • 能初始化脚本运行时
  • 能加载 ScriptCore
  • 能加载测试脚本程序集

类发现

  • 只发现继承 MonoBehaviour 的类
  • 忽略抽象类
  • 忽略普通工具类

生命周期

  • 能创建托管实例
  • 能按顺序触发 Awake -> OnEnable -> Start -> Update
  • 能在停止时触发 OnDisable -> OnDestroy

组件桥接

  • 脚本能访问 GameObject
  • 脚本能访问 Transform
  • 脚本能访问最小组件 API

字段系统

  • 能发现公共字段
  • 能读写字段
  • 字段缓存可回填到托管实例
  • 场景序列化后字段值不丢失

多脚本对象

  • 同一 GameObject 上多个 ScriptComponent 都可实例化
  • 不同脚本实例之间不会串字段

UUID 绑定

  • 运行时复制或反序列化后仍可稳定恢复脚本绑定键

12.4 第一阶段不要求的测试

  • editor Inspector UI
  • 热重载
  • 编译器输出面板
  • 文件监听
  • 资源拖拽

13. 建议的实现顺序

阶段 A补运行时地基

先补以下基础能力:

  • GameObject UUID 序列化
  • 层级递归更新
  • Start 一次性语义
  • SetActive 对生命周期的影响
  • Scene 运行时启动/停止接口

阶段 B脚本最小闭环

完成:

  • Scripting 模块骨架
  • ScriptComponent
  • MonoScriptRuntime
  • XCEngine.ScriptCore.dll
  • GameScripts.dll
  • 最小 InternalCall

阶段 C字段与序列化

完成:

  • 脚本字段元数据
  • 字段缓存
  • ScriptComponent 序列化/反序列化
  • 字段相关单元测试

阶段 D最小运行时演示

在不依赖 editor 的前提下完成:

  • 纯运行时测试场景
  • 一个或两个最小脚本示例
  • Play 级别运行验证

阶段 Eeditor 集成

后续再做:

  • 脚本组件 Inspector
  • 类选择器
  • 编译按钮
  • 错误输出
  • 重载流程

14. 与 editor 的关系

第一阶段文档明确规定:

  • C# 脚本模块不依赖 editor 完整落地
  • editor 相关工作全部后置
  • editor 相关缺口统一记录在 docs/issues

第一阶段唯一允许与 editor 共用的内容是:

  • 场景序列化格式
  • GameObject UUID 语义
  • 运行时场景副本的总体设计方向

除此之外,不应把脚本模块第一阶段实现建立在 editor 已具备以下能力的假设上:

  • Play/Simulate 控制条
  • Inspector 自定义字段绘制
  • 项目内脚本自动编译
  • 脚本异常面板

15. 风险与权衡

15.1 Mono 依赖

风险:

  • Windows 环境需要显式安装或打包 Mono
  • CMake 配置与发布路径管理会变复杂

权衡:

  • 第一阶段优先解决“能跑起来”的问题
  • 运行时抽象保留未来替换空间

15.2 生命周期与现有引擎实现的偏差

风险:

  • 如果继续保留当前“创建对象就立即 Awake”的行为,脚本生命周期会混乱

权衡:

  • 脚本系统建设必须带动运行时生命周期整理

15.3 字段系统范围

风险:

  • 一开始就追求完整序列化会让范围失控

权衡:

  • 第一阶段只做基础类型与少量引用类型
  • 复杂容器和高级序列化后置

15.4 editor 后置

风险:

  • 第一阶段用户体验不完整

权衡:

  • 可以显著降低实现风险
  • 可以先通过单测和运行时样例把底层做稳

16. 最终结论

XCEngine 的 C# 脚本系统应当采用:

  • ScriptComponent + MonoBehaviour
  • ScriptCore + GameScripts 双程序集结构
  • InternalCall 桥接
  • Mono 作为第一套运行时实现
  • UUID 作为绑定与序列化的稳定身份
  • 第一阶段只做运行时与单元测试,不依赖 editor

这条路线既保留了 Unity 风格的一致性,也能适配当前工程实际进度。

第一阶段的成功标准不是“Inspector 能编辑脚本字段”,而是:

  • 能加载脚本程序集
  • 能发现脚本类
  • 能在场景运行时驱动 MonoBehaviour
  • 能通过单元测试验证生命周期、字段和绑定语义

达到这一点后,再进入 editor 集成阶段,工程风险会显著更低。