「Do you believe yourself?」
我想亲眼见证,这荣耀之路的终点!
——摘自《ADAMAS》,《刀剑神域 Alicization篇》第一部分OP
在明确了我们要达成的目标后,就要考虑如何下手了。
Minecraft,作为一个自由度极高的沙盒游戏,其逻辑的复杂性也是空前的。
从哪里开始呢?
问题分析
本质:外接屏幕和键鼠的世界模拟器
让我们先退一步,从游戏程序的本质开始考虑:
我们可以把游戏程序看作一个带着输入设备的世界模拟器:
- 玩家通过输入设备(例如键鼠)操作角色/UI,改变游戏世界的状态(行走/杀怪/使用熔炉和背包等等)
- 游戏世界的状态经由输出设备(例如屏幕),被呈现给玩家
- 游戏世界也会按一定的节奏进行演化,修改自己的状态(昼夜循环/机器运行/怪物生成与行动等等)
基于这一点,再结合函数式设计思想,我们 就得到了上图的三行伪代码。
我们接下来缩窄范围。就游戏服务端而言,客户端就是输入输出设备,我们需要:
- 接收来自客户端的包,理解包所对应的操作,并按照操作对游戏世界的状态进行修改
- 把游戏世界的改变编码成包,发给客户端
- 同时按一定节奏演化游戏世界
右边的伪代码就是这些操作的具象化表示,转换为模块和对象就是左边的图。
此外,游戏服务器还需要处理持久化(把世界状态定期落盘)等等其它事情。
小结
通过上面的分析,我们把游戏服务端的核心逻辑拆成了两个部分,协议逻辑和游戏核心逻辑:
- 游戏逻辑负责演化游戏世界,也负责根据客户端的操作改变游戏状态。
- 协议逻辑负责把收到的包转化成高层的玩家操作,再把游戏世界的变化转换成包发给客户端。
这一划分其实有着诸多好处,例如解耦了核心算法与副作用(持久化/IO),例如把我们无法控制的东西(协议细节)扔出了核心算法,具体请见下文。
在完成了高层的拆解之后,接下来就是考虑怎么具体组织这些逻辑了。
游戏逻辑组织
承接上文的分析,游戏逻辑可以被建模成tick(world, action) = world。如果我们推后考虑action(玩家操作),那么只要弄清了如何建模tick(游戏逻辑)和world(游戏状态),就可以大概知道如何组织游戏逻辑了。
考虑到我们要实现的系统相当复杂,且对可扩展性有较高的要求,我认为ECS架构是一个不错的选择。
ECS
在ECS架构中:
- 游戏状态由一系列实体组成,每个实体持有自己的ID和一些组件。组件是一些状态的集合,多和某个功能有关,例如位置,生命值,物理,AI等等。
- 游戏逻辑由一系列系统组成,系统负责对某类或某几类组件增删改查,实现游戏功能。
- 状态和逻辑是分离的,组件和实体不持有逻辑,系统不持有状态。
以上图为例,我们有三个实体。ID=1的实体有物理组件,位置组件和怪物AI组件。物理系统可能会在每个tick扫描所有拥有位置和物理组件的实体,检查其脚下方块实体的方块物理组件,进而决定要不要修改位置组件让这个实体往下掉。
挺好的,不过等等,增删改查?再仔细看看,我怎么感觉这操作我在哪里见过......
草,万物本质皆是CRUD
ECS,但是......
站在ECS架构的角度,实现MC看起来也不难:只要找个ECS框架,把所有东西(方块,怪物,物品)都实现成ECS实体,游戏逻辑写成ECS系统,就万事大吉了!
当然,要是事情这么简单,我也不会在这里了。
以方块为例:一个区块大概有16x16x(320-(-64))=98,304个方块,哪怕50%是空气也有接近50K个方块!一个服务端大概要能承载1000个区块,就是50M个,五千万个方块!
我不认为现有的ECS框架可以有效地存储这么大量的实体。更何况要充分利用它们绝大部分都是少数类型的实例的特点,节约存储空间。
如果我们还想要使用ECS架构,我们就需要特殊设计来对付这些问题!
为了实现压缩存储,我们引入“实体类型”和“默认组件”的概念:
- 我们要求ECS实体必须拥有一个不变的类型组件,来表达其类型
- 每种实体类型都可以拥有“默认组件”, 对应类型的实体自动拥有对应的组件,除非在实体级别进行覆盖
这样设计下来,对于一个实体,我们就只需要存储其类型和其覆盖的组件,而非所有组件,进而节省空间。
设计方案
好了,讲了这么多抽象的思想,具体而言,我们到底要怎么组织游戏状态和逻辑呢?
游戏状态
让我们先从游戏状态开始。
我们先退一步,考虑一下MC世界到底由哪些东西组成:
- 一个MC存档里有许多维度(主世界,地狱,末地等)
- 一个维度里有许多实体(玩家,怪物等等)和方块
- 实体可以拥有背包,背包里存放物品
- 方块还会被组织成子区块和区块,有些信息会和这些概念绑定
于是,我们结合ECS的思想,把维度,方块和背包物品各当成一种特殊的实体,就得到了下面的这个设计:
- 游戏状态数据库(GameDB)负责作为游戏状态的单一事实来源(Single Source of Truth)
- 维度,方块,子区块和区块,实体,以及背包和物品都采用ECS实体的方式进行存储
- 其中,背包不持有组件,只持有元信息(背包的名字,所属实体,以及大小)和所拥有的物品,物品才是标准的ECS实体
此外,我们还要允许游戏逻辑监听变化,以提升游戏逻辑的执行效率——只需要关心变化的实体,而不用每次执行都遍历相关实体。
游戏逻辑
至于游戏逻辑,我们承接上文的函数式设计,但采用面向对象的方式进行实现。
毕竟,要真上函数式的话,性能且不论,光是思路上的差异就够插件开发者们喝一壶的了。
游戏服务器(GameServer)由上下文(Context)和系统(System)组成,它们可以被配置钩子(Configurator)配置:
- 上下文负责持有基础设施和游戏状态,其中包括:
- 游戏注册表(Registries),负责持有实体类型,默认组件等信息
- 游戏状态数据库(GameDB),负责持有游戏状态,即公式中的world
- 事件总线(EventBus),负责给系统监听变化,互发消息通信
- 其它插件或逻辑外挂的对象,例如:
- 数据库连接池
- 操作更新信箱(mailbox),负责和协议逻辑的交互:提供公式中的action,收集公式中的update
- 系统负责实现游戏逻辑,可以:
- 按一定顺序参与游戏刻循环(tick loop)
- 通过依赖注入拿到实例,和上下文中的对象(例如状态数据库)进行交互
- 通过依赖注入拿到实例,和其它系统进行交互
- 通过事件总线和其它系统进行交流
- 配置钩子(configurator)作为已有对象的扩展点,负责提供扩展机制(例如注册新组件,新方块等等)
存储/地形生成逻辑可以被游戏状态数据库使用,实现游戏世界的生成,加载和卸载。
协议逻辑可以监听游戏状态数据库的改变,把改变序列化后下发给客户端。同时通过上下文对象(例如信箱)来向系统提供来自客户端的高层操作信息,或者提供其它功能。
协议逻辑组织
和游戏逻辑框架的豪华设计比起来,协议逻辑这边就要简han单suan得多了:
整个逻辑被分为三层:
- 游戏协议层(exchange),负责:
- 完成真正的翻译操作,把客户端的包翻译成高层操作(action),把游戏世界的变化和更新(update)变成包发回给客户端
- 负责和协议细节高度耦合的逻辑,例如UI
- 序列化逻辑(serial)也位于这一层,负责ECS数据格式和协议数据格式的互相转换
- 连接协议层(handler),负责:
- 和游戏世界关系不大的协议,例如登录认证,资源包下发,ping等等
- 其中游戏协议处理器(GameHandler)是游戏协议层的入口
- 链路协议层(server),负责对付底层raknet协议的细节
简单其实也是件好事,因为这部分的逻辑极其难以测试——单元测试在这里作用有限,最终还是要靠玩家测试踩坑,这种东西当然越简单越好。
优劣分析
好了,我苦心孤诣,反复推敲推出来了这么一吨东西,有啥好处呢?
让我们回顾一下前面的架构设计决策,分析其优劣。
游戏/协议逻辑解耦合
优势
这样设计能把协议逻辑和游戏逻辑拆开,进而可以:
- (复杂度)让游戏逻辑需要关心的东西变少,更容易编写,测试和理解,让协议逻辑更加明显,便于分析理解
- (API稳定性)让游戏逻辑的API和设计更加稳定,因为我们不能控制的变化来源(协议细节)被拆了出去
- (优化难度)让协议逻辑的优化更容易进行,可以让缓存/并行化更加简单明了,而不是和游戏状态/逻辑混在一起
- (分布式)让协议-逻辑分布式更容易进行——我们只要让网关服务器和逻辑服务器分别运行这两个部分即可
劣势
- (性能开销)翻译会带来性能开销
- (认知开销)解耦合和抽象会增加系统中的概念数量。对于简单的系统而言,不抽象反而更简单,而且错误的抽象比不抽象更坏
ECS架构(类函数式,数据驱动)
优势
采用ECS架构这种数据驱动且类函数式风格(状态/行为分离,整个程序都是状态变换)的视角会带来这些好处:
- (组织)ECS架构可以被看成一个心智模型的框架。基于这个框架,各类基础的游戏逻辑(物理,战斗)都可以被视为组件和系统,各自组成一个个完整的小心智模型。我们再把这些模型组装起来,实现功能。
- (性能)ECS由于鼓励使用“列(组件)”而非“行(实体)”的视角看待逻辑,使得很多逻辑实现上都是在按序处理由定长组件组成的数组,这对cache更友好
- (并行化)由于没有了数据封装和多态,并行化就变得非常简单明了:假设某个系统内没有跨实体的状态修改,那么就可以直接对所有待处理实体进行并行操作
- (可探查性)由于ECS架构里的状态由没有封装的实体和组件构成,把这种数据dump成json显示易如反掌,这让开发探查工具变得相当简单
- (可观察性)由于我们将逻辑拆成了一个个系统,只需要记录系统的执行用时,就能知道哪些游戏逻辑在烧时间。加上埋点,甚至可以知道时间花在了哪些区块和实体上
- (开发体验)我们拆分了状态和逻辑,这使得逻辑(以及其持有的辅助状态,例如索引)可以独立演化,进而可以轻松实现安全的热重载
- (代码生成)由于状态定义和行为分离,我们可以基于原版游戏逆向出的数据自动推导组件定义,进而减少升级游戏版本所需的工作量
呼应前文
我们在上一篇文章中提到了除了控制复杂度外,还需要满足的其它要求。我们看看这个设计是如何满足它们的:
- (架构质量)我们基于ECS架构,将整个游戏逻辑拆成了由一系列组件和系统构成的一个个切面,实现了正交分解。这一模型也支持编写新的切面,实现了可扩展性。
- (开发者体验)ECS架构对探查工具友好,支持状态逻辑独立演化,进而支持热重载
- (运维体验)ECS架构对可观察性友好,可以让日志埋点,指标采集都更加简单统一
劣势
- (理解难度)ECS倡导运行时拼装组件。这使得我们没法在编译期确定一个实体所拥有的组件,进而会加大理解难度
- (分布式)f(world)=world的模式让分布式分片计算变得困难,搞起来可能需要实时级别的map-reduce
其它考量
经过上面的设计,我们勉强算是拿下了整个服务端最复杂的部分——游戏逻辑和协议逻辑。
但服务端不止有这些东西,我们还需要不少基础设施作为支撑:文件系统,插件,存储,终端控制台等等。更何况前面吹的牛逼也涉及到工具。
更操蛋的是,这里的每一个模块,都有一大堆需要考虑的额外要素。
这些考量大概可以组织成三个切面:
- 生命周期与错误处理切面
- 这个模块是否参与服务器启停周期
- 出错了是否所有错误都有地方汇报,出错了是直接上报框架停掉服务器,还是可以局部重启?
- 扩展与开发体验切面
- 在哪些地方提供扩展,API怎么设计
- 热重载要怎么做,哪些方便开发者的工具需要提供
- 可观测性与运维体验切面
- 打什么日志,记录什么监控指标,提供什么内省信息
- 要提供哪些运维工具
我好像TMD,挖了一个大坑......
总结
A上去了啊,板桥兄!
开大了啊,板桥兄!
被秒了啊,板桥兄!
在本文中,我们从游戏程序的本质出发,推导出了游戏逻辑和协议逻辑分离的架构。再将ECS架构和MC的特殊要求结合,提出了具体的游戏逻辑设计,并讨论了这些设计决策的优劣。
最后,我们还瞥了一眼完成一个服务端所需要考虑的其它事情,并且深刻地意识到了这是一个天坑。