# 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 风格生命周期接口 - `Scene` 和 `GameObject` 已有序列化入口 - `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` - **程序集模型**:`ScriptCore` 与 `GameScripts` 分离 - **运行时加载模式**:独立编译,运行时加载 - **桥接方式**:`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` 模块: ```text 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`: ```text 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` - 由项目侧维护 - 编写具体游戏脚本 关系如下: ```text GameScripts.dll └── 引用 XCEngine.ScriptCore.dll Engine Core ├── 先加载 XCEngine.ScriptCore.dll └── 再加载 GameScripts.dll ``` --- ## 7. 对象模型 ### 7.1 托管侧模型 托管侧应当采用接近 Unity 的对象层次: ```text Object └── Component └── Behaviour └── MonoBehaviour ``` 其中: - `Object`:基础托管对象 - `Component`:挂载到 `GameObject` 上的托管组件基类 - `Behaviour`:带 `enabled` 语义的组件 - `MonoBehaviour`:用户脚本直接继承的基类 ### 7.2 原生挂载模型 原生侧脚本挂载使用 `ScriptComponent`,而不是 `ScriptContainerComponent`。 原因如下: - 更符合 Unity 认知模型 - 更容易与现有 `GameObject::AddComponent` 思路对齐 - 更容易在未来做 Inspector 级脚本组件显示 - 更容易把字段序列化与组件实例对应起来 建议 `ScriptComponent` 至少包含: - `scriptComponentUUID` - `assemblyName` - `namespaceName` - `className` - `enabled` - `fieldStorage` 推荐接口示意: ```cpp 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` - 自定义托管结构体 - 嵌套对象图 - 泛型容器 - 资源引用对象选择器 --- ## 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` - `GameObject.GetComponent` - `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 测试目录建议 ```text 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 级别运行验证 ### 阶段 E:editor 集成 后续再做: - 脚本组件 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 集成阶段,工程风险会显著更低。