「Do you believe yourself?」
我想亲眼见证,这荣耀之路的终点!
——摘自《ADAMAS》,《刀剑神域 Alicization篇》第一部分OP
在明确了我们要达成的目标后,就要考虑如何下手了。
Minecraft,作为一个自由度极高的沙盒游戏,其逻辑的复杂性也是空前的。
从哪里开始呢?
让我们先退一步,从游戏程序的本质开始考虑:
我们可以把游戏程序看作一个带着输入设备的世界模拟器:
基于这一点,再结合函数式设计思想,我们 就得到了上图的三行伪代码。
我们接下来缩窄范围。就游戏服务端而言,客户端就是输入输出设备,我们需要:
右边的伪代码就是这些操作的具象化表示,转换为模块和对象就是左边的图。
此外,游戏服务器还需要处理持久化(把世界状态定期落盘)等等其它事情。
通过上面的分析,我们把游戏服务端的核心逻辑拆成了两个部分,协议逻辑和游戏核心逻辑:
这一划分其实有着诸多好处,例如解耦了核心算法与副作用(持久化/IO),例如把我们无法控制的东西(协议细节)扔出了核心算法,具体请见下文。
在完成了高层的拆解之后,接下来就是考虑怎么具体组织这些逻辑了。
承接上文的分析,游戏逻辑可以被建模成tick(world, action) = world。如果我们推后考虑action(玩家操作),那么只要弄清了如何建模tick(游戏逻辑)和world(游戏状态),就可以大概知道如何组织游戏逻辑了。
考虑到我们要实现的系统相当复杂,且对可扩展性有较高的要求,我认为ECS架构是一个不错的选择。
在ECS架构中:
以上图为例,我们有三个实体。ID=1的实体有物理组件,位置组件和怪物AI组件。物理系统可能会在每个tick扫描所有拥有位置和物理组件的实体,检查其脚下方块实体的方块物理组件,进而决定要不要修改位置组件让这个实体往下掉。
挺好的,不过等等,增删改查?再仔细看看,我怎么感觉这操作我在哪里见过…
草,万物本质皆是CRUD
站在ECS架构的角度,实现MC看起来也不难:只要找个ECS框架,把所有东西(方块,怪物,物品)都实现成ECS实体,游戏逻辑写成ECS系统,就万事大吉了!
当然,要是事情这么简单,我也不会在这里了。
以方块为例:一个区块大概有16x16x(320-(-64))=98,304个方块,哪怕50%是空气也有接近50K个方块!一个服务端大概要能承载1000个区块,就是50M个,五千万个方块!
我不认为现有的ECS框架可以有效地存储这么大量的实体。更何况要充分利用它们绝大部分都是少数类型的实例的特点,节约存储空间。
如果我们还想要使用ECS架构,我们就需要特殊设计来对付这些问题!
为了实现压缩存储,我们引入“实体类型”和“默认组件”的概念:
这样设计下来,对于一个实体,我们就只需要存储其类型和其覆盖的组件,而非所有组件,进而节省空间。
好了,讲了这么多抽象的思想,具体而言,我们到底要怎么组织游戏状态和逻辑呢?
让我们先从游戏状态开始。
我们先退一步,考虑一下MC世界到底由哪些东西组成:
于是,我们结合ECS的思想,把维度,方块和背包物品各当成一种特殊的实体,就得到了下面的这个设计:
此外,我们还要允许游戏逻辑监听变化,以提升游戏逻辑的执行效率——只需要关心变化的实体,而不用每次执行都遍历相关实体。
至于游戏逻辑,我们承接上文的函数式设计,但采用面向对象的方式进行实现。
毕竟,要真上函数式的话,性能且不论,光是思路上的差异就够插件开发者们喝一壶的了。
游戏服务器(GameServer)由上下文(Context)和系统(System)组成,它们可以被配置钩子(Configurator)配置:
存储/地形生成逻辑可以被游戏状态数据库使用,实现游戏世界的生成,加载和卸载。
协议逻辑可以监听游戏状态数据库的改变,把改变序列化后下发给客户端。同时通过上下文对象(例如信箱)来向系统提供来自客户端的高层操作信息,或者提供其它功能。
和游戏逻辑框架的豪华设计比起来,协议逻辑这边就要简han单suan得多了:
整个逻辑被分为三层:
简单其实也是件好事,因为这部分的逻辑极其难以测试——单元测试在这里作用有限,最终还是要靠玩家测试踩坑,这种东西当然越简单越好。
好了,我苦心孤诣,反复推敲推出来了这么一吨东西,有啥好处呢?
让我们回顾一下前面的架构设计决策,分析其优劣。
这样设计能把协议逻辑和游戏逻辑拆开,进而可以:
采用ECS架构这种数据驱动且类函数式风格(状态/行为分离,整个程序都是状态变换)的视角会带来这些好处:
我们在上一篇文章中提到了除了控制复杂度外,还需要满足的其它要求。我们看看这个设计是如何满足它们的:
经过上面的设计,我们勉强算是拿下了整个服务端最复杂的部分——游戏逻辑和协议逻辑。
但服务端不止有这些东西,我们还需要不少基础设施作为支撑:文件系统,插件,存储,终端控制台等等。更何况前面吹的牛逼也涉及到工具。
更操蛋的是,这里的每一个模块,都有一大堆需要考虑的额外要素。
这些考量大概可以组织成三个切面:
我好像TMD,挖了一个大坑…
A上去了啊,板桥兄!
开大了啊,板桥兄!
被秒了啊,板桥兄!
在本文中,我们从游戏程序的本质出发,推导出了游戏逻辑和协议逻辑分离的架构。再将ECS架构和MC的特殊要求结合,提出了具体的游戏逻辑设计,并讨论了这些设计决策的优劣。 最后,我们还瞥了一眼完成一个服务端所需要考虑的其它事情,并且深刻地意识到了这是一个天坑。
回头
哦,对了,顺带一提:开发语言是kotlin,框架上了Spring Boot。
kotlin的语法糖确实很甜,但这不是重点。
重点是kotlin有协程(准确地说是One-shot Delimited Continuations),这个语言特性能让异步操作写起来更加简单明了(例如网络协议,动画等等),仅此而已。
按理来说Spring Boot是拿来做web应用的,而且非常笨重。
但是我发现我需要一个DI容器,再加上Spring Boot能替我操心依赖的版本问题(我只需要关心Spring Boot的版本,而不是直接操心一大堆基础库的版本),再加上Spring Boot有很成熟的发布和监控支持,再加上庞大的生态和大量的文档教程。
就果断真香了
跑路