架构说明
本文描述 Space Engineers Blueprint Editor 当前代码的主要边界、依赖方向和运行时数据流。构建操作见开发指南。
设计约束
项目的核心难点不是 XML 本身,而是在游戏进程外复用《Space Engineers》的 ObjectBuilder、定义和序列化系统。架构由以下约束塑造:
- 游戏程序集和内容受游戏安装约束,不能随仓库或应用分发。
- 游戏核心类型仍面向 .NET Framework,桌面界面则使用 .NET 8 和 WinUI 3。
- 部分 VRage/Sandbox 初始化通常依赖完整游戏平台与渲染环境,编辑器只需要其中的定义和序列化能力。
- 蓝图结构层级深、类型多,手写每一种属性编辑器维护成本很高。
- 保存结果必须保持游戏可识别的 ObjectBuilder XML 结构。
系统上下文
flowchart TB
subgraph External["外部数据"]
Steam["Steam 安装与库清单"]
Bin["Space Engineers / Bin64"]
Content["Space Engineers / Content"]
Blueprints["用户 Blueprint 目录"]
end
subgraph Product["Space Engineers Blueprint Editor"]
UI["WinUI 3 主程序"]
Loader["GameAssemblyLoader"]
Core["SpaceEngineersCore"]
Converter["BlueprintConverter"]
Installer["WPF Installer"]
end
Steam --> Loader
Loader --> Bin
Bin --> Core
Content --> Core
Blueprints --> UI
UI --> Loader
UI --> Core
Converter -.->|"独立辅助转换"| Bin
Converter -.-> Blueprints
Installer -.->|"安装或升级"| UI
项目依赖
flowchart LR
App["SpaceEngineersBlueprintEditor<br/>net8.0-windows"] --> Loader8["GameAssemblyLoader<br/>net8.0"]
App --> Core["SpaceEngineersCore<br/>net48"]
Converter["BlueprintConverter<br/>net48"] --> Loader48["GameAssemblyLoader<br/>net48"]
Converter --> Core
Test["Test console<br/>net8.0"] --> Loader8
Test --> Core
Installer["Installer<br/>net8.0-windows"]
Core --> Game["游戏程序集<br/>Private=false"]
App --> Game
Converter --> Game
Test --> Game
GameAssemblyLoader 多目标编译,避免为 .NET 8 主程序和 .NET Framework 转换器维护两套游戏定位代码。Directory.Build.targets 为所有声明 UseSpaceEngineersAssemblies=true 的项目注入游戏引用。
组件职责
WinUI 主程序
SpaceEngineersBlueprintEditor 负责用户交互与应用编排:
Views:页面布局、文件拖放和少量必须依赖控件事件的桥接逻辑。ViewModels:页面状态、命令、异步加载和导航参数处理。Model:面向 UI 的蓝图、属性和游戏定义视图数据。Interface/Services:集合显示、拖放、标题、树和背景等抽象。Implements/Services:控件适配和具体事件实现。Utilities:路径、目录扫描、反射分析、文件操作和游戏帮助方法。Profiles:主题、导航样式、游戏路径等持久配置。
ViewModel 使用 CommunityToolkit.Mvvm 的源生成属性和命令。跨页面导航、消息、加载遮罩和选择状态通过 ServiceManager 中的全局或页面服务协调。
GameAssemblyLoader
加载器刻意不引用游戏类型,确保可以在游戏程序集尚未加载时先完成定位:
GameInstallationLocator枚举首选路径、环境变量、注册表、Steam 库和默认磁盘位置。- 候选路径被规范化成游戏根目录并验证关键文件。
GameAssemblyLoader保存GameRootPath与GameBinPath。- 注册
AppDomain.AssemblyResolve,按简单程序集名从Bin64加载托管 DLL。 - 调用
SetDllDirectory,支持 Havok 等原生依赖解析。
加载器需要在任何游戏类型首次触发静态加载前初始化,这也是入口代码把相关调用放在辅助方法外层的原因。
SpaceEngineersCore
核心层把游戏运行环境裁剪到“能够加载定义和序列化 ObjectBuilder”的程度:
Initializer配置MyFileSystem、VRage 平台、任务调度器、空渲染器、最小MySession和插件程序集。MyDefinitionManager从游戏Content预加载并加载定义。MyTexts按当前 UI Culture 加载游戏本地化文本。SpaceEngineerDefinitions通过 Keen 的序列化器读取 ObjectBuilder XML,并以同目录临时文件、原子替换和sbcB5备份保存。BlueprintEditing根据当前定义转换网格尺寸/装甲,并读写网格 Skeleton 形变数据。ImageConverter与DxtUtil处理游戏 DDS 图标到 UI 可用图像的转换。GridConverter包含外部网格格式转换定义和导出辅助代码。InnerModel提供无窗口环境所需的平台、系统和渲染最小实现。
InnerModel 中大量成员故意没有实现:当前初始化路径不会使用它们。一旦新增功能触发这些成员,需要实现最小语义或重新评估是否应该依赖完整游戏环境。
BlueprintConverter
该项目提供独立的 net48 JSON → .sbc 转换入口:它把带类型信息的 JSON 反序列化成 MyObjectBuilder_Definitions,再用 XmlSerializer 生成文件。主界面当前已经直接调用核心层的 SpaceEngineerDefinitions.Save,不再把转换器作为正常保存路径。
转换器协议不是面向用户的通用文件格式。JSON 开启了 TypeNameHandling.Auto,只应处理编辑器自己生成的受信任输入;保留该项目时应明确它的兼容或迁移用途。
Installer
安装器是独立 WPF 应用,负责:
- 选择安装目录并展示许可/隐私文本。
- 下载和暂停/继续升级包。
- 解压新安装或升级内容。
- 创建快捷方式并启动主程序。
它不参与蓝图读取和编辑路径。
启动时序
sequenceDiagram
participant Entry as App
participant Locator as GameInstallationLocator
participant Loader as GameAssemblyLoader
participant UI as MainWindow/AppShell
participant BPM as BlueprintsManager
participant Core as Initializer
participant Defs as MyDefinitionManager
Entry->>Loader: Initialize(保存的游戏路径)
Loader->>Locator: FindGameRootPath
Locator-->>Loader: 有效游戏根目录或 null
Loader->>Loader: 注册 AssemblyResolve 与原生 DLL 目录
Entry->>UI: 创建窗口、注册页面和服务
par 扫描蓝图
Entry->>BPM: LoadBlueprintsAsync
BPM-->>Entry: 本地/云端/工坊索引
and 加载游戏定义
Entry->>Loader: EnsureInitialized
Entry->>Core: Initialize(Content, UserData)
Core->>Defs: PreloadDefinitions / LoadData
Defs-->>Core: 定义集合
end
Entry->>UI: Activate
蓝图扫描和定义初始化并行执行。依赖定义的页面通过 Helper.Wait 等待 Initializer.IsDefinitionsLoadComplete;这让首屏更快出现,但也意味着初始化异常必须通过全局消息服务显式反馈。
蓝图读取流程
flowchart LR
Dir["蓝图子目录"] --> Scan["BlueprintsManager"]
Scan --> Info["BlueprintInfo"]
Info --> ViewData["BlueprintInfoViewData"]
ViewData --> DetailVM["BlueprintDetailPageViewModel"]
DetailVM --> Load["SpaceEngineerDefinitions.Load"]
Load --> Definitions["MyObjectBuilder_Definitions"]
Definitions --> Ship["第一个 ShipBlueprint"]
Ship --> Stats["网格 / DLC / 组件统计"]
Ship --> EditorVM["BlueprintEditSubPageViewModel"]
BlueprintsManager 只建立轻量目录索引;进入详情或编辑器后才反序列化完整 .sbc。视图数据同时携带名称、大小、路径和缩略图,供页面之间作为导航参数传递。
属性编辑模型
蓝图类型数量多,编辑器采用反射生成属性树:
SpaceEngineersHelper.AnalyzeBlueprint检查对象的公开可写字段和属性。- 每个成员包装为
BlueprintPropertyViewData,保留名称、类型、值和父节点。 - 对象和集合节点按需展开,避免一次性构建整棵巨大树。
ShipBlueprintItemTemplateSelector根据基础值、枚举、集合、CubeGrid、CubeBlock 等类型选择模板。SetValue通过反射回写父对象;值类型的修改继续向祖先传播,避免只修改装箱副本。
flowchart TD
Root["ShipBlueprint / CubeBlock"] --> Reflect["反射公开可写成员"]
Reflect --> Node["BlueprintPropertyViewData"]
Node --> Kind{"节点类型"}
Kind -->|"基础类型或枚举"| Control["输入控件"]
Kind -->|"对象"| Lazy["延迟展开子成员"]
Kind -->|"IEnumerable"| Items["延迟展开集合项"]
Control --> Set["反射写回父对象"]
Set --> Bubble["值类型向祖先回写"]
该方案覆盖面广,但缺少针对游戏语义的范围验证。新增专用编辑器时,应优先在类型选择或转换层加入校验,而不是把复杂逻辑塞入通用反射节点。
三维与钣金编辑
编辑器在通用属性树之外提供两条专用路径:
flowchart LR
Ship["MyObjectBuilder ShipBlueprint"] --> Builder["Blueprint3DSceneBuilder"]
Builder --> Json["紧凑场景 JSON"]
Json --> WebView["WebView2 + 本地 WebGL 2"]
WebView -->|"选择 / action 消息"| VM["BlueprintEditSubPageViewModel"]
VM --> Actions["移动 / 旋转 / 复制 / 删除"]
Actions --> Undo["三维撤销与重做栈"]
VM --> Refresh["重建场景并回传 WebView"]
Ship --> Grid["选中的 CubeGrid / CubeBlock"]
Grid --> Deform["CubeDeformationEditor"]
Deform --> Service["CubeDeformationService"]
Service --> Skeleton["CubeGrid.Skeleton BoneInfo"]
三维场景使用定义尺寸、蓝图方向、网格姿态和颜色构造方块包围盒。HTML/JavaScript 资源通过 WebView2 虚拟主机映射从应用 Assets 加载,页面与 C# 只交换 JSON 消息,不访问网络。每次操作后重新生成紧凑场景,异步 generation 编号用于丢弃过期结果。
三维撤销栈保存成对的 Apply/Undo 委托,仅覆盖该视图发起的变换。移动和删除会同步更新方块组坐标,复制会克隆 ObjectBuilder 并清空 EntityId。当前占位检测以方块最小坐标为主,不能替代完整多格碰撞检测。
钣金编辑器把 8 个角点和 6 个面中心映射到游戏的 3×3×3 局部 Skeleton 点阵,通过 SerializableVector3UByte 编解码 [-1, 1] 偏移。中性偏移会删除对应 Bone,避免写入冗余 Skeleton 数据。
保存流程
sequenceDiagram
participant User as 用户
participant Editor as WinUI 编辑器
participant Temp as 同目录临时文件
participant Core as SpaceEngineerDefinitions
participant Target as 目标 .sbc
User->>Editor: 保存或另存为
Editor->>Core: Save(path, definitions)
Core->>Temp: Keen SerializeXML
alt 目标已存在
Core->>Target: File.Replace(temp, target, target + B5)
else 新文件
Core->>Target: File.Move(temp, target)
end
Core->>Temp: finally 清理残留临时文件
Core-->>Editor: 成功或异常
临时文件和目标文件位于同一目录,以便使用文件系统原子替换。目标存在时,旧版本成为 目标路径 + "B5";已有备份会被替换。UI 在后台线程执行序列化,并通过消息服务报告异常。
游戏定义与图标
定义页和组件统计都以 MyDefinitionManager.Static 为权威来源。SpaceEngineersHelper 暴露分类枚举,并把定义图标从游戏路径转换为 PNG 后放入应用缓存。这样 UI 不必直接解码 DDS,也避免每次启动重复转换。
缓存可以从设置页清除。游戏更新、图标路径变化或缓存损坏时,清除缓存会触发重新生成。
主要状态与边界
| 状态 | 所有者 | 生命周期 |
|---|---|---|
| 游戏根目录 | SystemProfile.GameRootPath / GameAssemblyLoader |
跨启动持久化;加载器在进程内缓存 |
| 蓝图索引 | BlueprintsManager 静态列表 |
进程内,可刷新 |
| 游戏定义 | MyDefinitionManager.Static |
进程级,一次初始化 |
| 当前蓝图对象 | 详情/编辑 ViewModel | 页面或编辑标签生命周期 |
| 属性树 | TreeView 服务与 BlueprintPropertyViewData |
按选择和展开动态重建 |
| 定义图标 | 应用缓存目录 | 跨启动,可清除 |
| UI 配置 | SystemProfile |
跨启动持久化 |
已知技术债与扩展方向
- 三维方块使用包围盒近似,移动/复制的占位判断还不是完整的多格碰撞检测。
- 撤销/重做只覆盖三维操作,属性、Skeleton 形变和批量转换尚未进入统一命令系统。
- 网格尺寸和轻/重甲转换依赖当前游戏定义配对;需要更清晰地处理坐标缩放、缺失配对和可能重叠。
- 独立
BlueprintConverter与主界面直接保存形成两条序列化路径,需要明确长期保留或移除策略。 - 外部格式导出尚未连接到生产 UI 流程。
- 组件统计对未知、Mod 或版本不匹配定义需要更清晰的缺失报告。
- 全局静态游戏定义与多个 UI 服务增加了隔离测试难度。
async void事件路径较多,异常传播和取消控制仍可加强。- 缺少标准单元、集成与序列化回归测试项目。
新增核心功能时,优先保持依赖方向为“UI → 核心抽象 → 游戏 API”,并把需要游戏安装的集成测试与纯数据转换测试分开。