关于我写了个Minecraft基岩版服务端这件事(二):设计与实现

Sat, November 5, 2022 - 21 min read

「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的特殊要求结合,提出了具体的游戏逻辑设计,并讨论了这些设计决策的优劣。 最后,我们还瞥了一眼完成一个服务端所需要考虑的其它事情,并且深刻地意识到了这是一个天坑。

追加:技术选型

回头

哦,对了,顺带一提:开发语言是kotlin,框架上了Spring Boot。

kotlin的语法糖确实很甜,但这不是重点。

重点是kotlin有协程(准确地说是One-shot Delimited Continuations),这个语言特性能让异步操作写起来更加简单明了(例如网络协议,动画等等),仅此而已。

按理来说Spring Boot是拿来做web应用的,而且非常笨重。

但是我发现我需要一个DI容器,再加上Spring Boot能替我操心依赖的版本问题(我只需要关心Spring Boot的版本,而不是直接操心一大堆基础库的版本),再加上Spring Boot有很成熟的发布和监控支持,再加上庞大的生态和大量的文档教程。

就果断真香了

跑路