这篇文章, 不介绍通用的技术, 专门介绍游戏服务器内能用上的技术及组件等.
网络协议
拨号上网的时代, 丢包率高, 网速慢, 那时候的通讯协议用UDP, 甚至一些类似于IP数据包的协议. 数据包的大小受限, 然后通过应用层重传来解决丢包问题.
TCP
现在的网络环境已经比较好(越来越好), 延迟和丢包率都在降低, 所以绝大部分场景, 都是在使用TCP协议做为通讯协议. 但是有个别场景, 会使用一些UDP协议改造的TCP协议.
可靠UDP
比如enet, KCP, QUIC, 其中enet和KCP已经有大量游戏或者应用在使用, 所以客户端和服务器的port会比较多.
HTTP和WebSocket
HTML5提供WebSocket可以用来做长连接通讯, 有不少H5游戏通过HTTP和WS协议来和服务器通讯.
P2P
还有一种通讯方式比较少见, 就是客户端之间组成一个P2P网络, 传送一些战斗表现的包.
随着网络基础设施的不断完善, 这类技术会越来越少, 5年前上海ping北京的服务器可能有80到100ms的延迟, 现在(2018年)可能就50ms左右的延迟. 3G/4G/5G网络延迟状况也是类似的, 每次升级, 延迟都是大幅度降低.
游戏的同步技术
帧同步
服务器和客户端按照一定的帧率发送消息, 客户端拿到服务器广播的消息之后, 做replay, 从而让多个客户端之间达到同步.
常见于早期的局域网内游戏.
在需要强一致性的游戏中, 通常也才用一些关键帧同步的算法(帧锁定同步算法). 不是每一帧都锁定, 是隔几个关键帧才会进行锁定判断.
状态同步
服务器将计算后的结果, 同步给各个客户端. 客户端不必拿到所有的指令(不能作弊), 然后也可以做断线重连.
状态同步的时候, 因为服务器和客户端之间的延迟, 所以为了准确的计算玩家的位置, 需要用到一定的预测算法(影子跟随算法).
混合模式
帧同步的成本实际上比较高, 服务器要按照一定的帧率来跑, 而且不允许有大量玩家之间做交互, 只能做小范围的游戏. 状态同步和帧同步是一个互补的关系. 有时候为了动作表现比较协调, 会将动作指令先行同步出去, 然后再同步状态, 从而达到玩家体验和反外挂的平衡.
负载均衡
游戏服务器的负载很难做, 主要是因为游戏服务器大多数是有状态服务, 那么请求必须得路由到某一个固定的节点上去, 而不能被路由到其他节点上面去. 但是可以通过划分, 把这个节点上的实体数量减少, 来达到负载均衡的目的.
一个很简单的例子, 服务器内有大量的广播, 可以增加gateway的数量, 把gateway做成多个, 达到负载均衡的目的.
nginx
也有一些轻度的社交类型的服务器, 是使用无状态服务的做法, 那么他的负载均衡, 可能就是一个nginx或者http请求的负载均衡.
router
传统的有状态服务做负载划分的时候, 通过路由的方式把消息转发到另外的服务器上面, 通常会有一类叫router的服务器, 该服务器通常是无状态或者少量状态.
对象的定位, 大部分都是通过Hash(ID)的方式来定位, 或者在ID里面包含服务器的ID等.
object proxy
BigWorld在做负载均衡的时候, 将Entity划分到随机一个节点上面去, 然后在集群的其他节点上面生成ShadowObject, 跟ShadowObject的交互都会被转发到真实对象所在的服务器上面. 这实际上就是一个ObjectProxy模式.
Orleans在做负载均衡的时候, 是通过对象的UniqueID映射到一致性HashTable上, 然后客户端在访问的时候, 生成Proxy对象, 跟BigWorld的类似, 稍有区别.
服务器的AOI
服务器内的对象, 比如玩家和NPC, 都会对周围的物体比较感兴趣, 所以就有了AOI这个概念. 整体思路就是Hash或者二分.
网格
传统的2D或者3D MMOG都会使用网格这种方式来做AOI, 简单暴力, 配合网关效果还不错. 缺点是不能做视野变化范围比较大的游戏, 或者地图很小导致网格不好划分.
十字链表
对于动态视野(小场景)的游戏, 还有这种x轴和y轴各自弄一个链表, 物体的移动, 都是在x轴和y轴上面单独移动. 求视野只需要做一个交集.
四叉树/八叉树
动态视野也可以通过四叉树或者八叉树来解决, 思路就是二分法快速的收敛.
对于那种横板类型的2D游戏, 实际上可以考虑二分法, 而不必非要做四叉树, 因为Y轴上的分布可能不是那么明显, 单单X轴的分布已经可以很好的区分对象.
AI
服务器的AI技术, 会随着游戏类型出现多种变化.
寻路和A*
不管是2D游戏, 还是3D游戏, 都或多或少有寻路的需求.
寻路的场景分为两种, 一种是短距离的寻路, 一种是长距离的寻路.
短距离+无碰撞的游戏, 可以通过直奔目标的方式.
长距离才需要寻路算法, 一般都是A*算法, 差异大多数数据结构方面的差异, 比如小方格地图, 六边形地图, 或者导航网格使用的三角形地图.
算法找到的路径, 有可能不是那么自然, 还需要对寻路的路径进行优化(lazy theta). 否则因为地图数据的原因(格子和三角形), 会导致寻路结果不是那么自然.
状态机和行为树
MMOG游戏服务器大多数使用状态机这种方式来做Game AI, 状态空间一旦比较庞大, 状态机就很难维护, 所以出现了分层有限状态机和行为树. 大致的思想就是让不同的状态之间不能随意的迁移, 层次或者组之间可以迁移, 进而减少状态空间的大小, 让人脑可以掌握这个状态空间. 但是不幸的是, 大部分策划都不能正确配置行为树或者分层有限状态机.
如果怪物的行为, 没有那么复杂, 实际上可以直接使用状态机; 只有怪物的行为比较复杂, 尤其像WoW这种游戏, BOSS的行为会有多个阶段, 这时候使用分层有限状态机和行为树才能带来好处.
碰撞检测
有一些单机游戏里面, NPC和玩家是有阻挡这个概念的. 但是因为模型和场景的复杂性, 有时候产生BUG, 将玩家卡在那边.
最早的2D MMOG, 里面也有比较小的阻挡, 是在地图上面弄了非常小的格子, 用来实现简单的碰撞检测. 但是弊端很明显, 玩家可以把路口堵死, 所以后面的MMOG里面玩家和NPC大部分都是没有阻挡. 所以玩家与玩家之间, 玩家与NPC之间, 一般是不需要做碰撞检测的.
大部分的碰撞检测, 都是玩家与地形之间. 为了防止外挂在地图上的不可行走区域行走, 2D地图一般都会导出一份划分很细小的格子, 标识是否可以行走或者类似的信息; 3D地图则需要生成物理引擎需要的地图数据, 然后通过物理引擎(PhysX或者Bullet)来做射线碰撞.
还有一种碰撞检测也比较有必要, 就是战斗时的仿真. 玩家扔出去的武器(子弹或者物体), 对其他玩家或者NPC造成伤害, 需要做一定程度的仿真, 但是可能不是通过物理引擎来做的.
游戏的架构
游戏服务器因为游戏内容的不同, 以及设计人员对于语言和系统的了解不同, 会出现各种形式的架构
单服
就是一个进程, 进程内处理的事情比较多. 现在也有一些服务器是这么做的, 只是比较少见.
分布式的单服
因为单服的承载问题, 把单服的功能拆分出来, 做成分布式的服务器, 是现在大部分游戏服务器的基础. 但是原理没有脱离于单服游戏服务器.
无状态的分布式服务器
有一些休闲游戏, 甚至稍微轻度的游戏, 会采用无状态服务器的做法. 就是通过负载均衡将请求路由到任意一个节点上, 然后读取缓存, 操作, 写缓存, 存档等.
虽然看上去很low, 但是好处是可以横向扩展. 这在分布式的单服里面, 是很难做到的.
有状态的分布式服务器
因为分布式单服设计的特点, 导致很难横向扩展, 所以出现了有状态的分布式服务器设计.
原理是, 通过一定的组件, 来确定对象分配的位置, 然后将请求路由到该处理节点上.
其实通过router这种组件实现的服务器, 已经具备扩展的能力, 无非就是对象分配是随机的, 固定的, 还是一致性Hash等算法.
随机分配的话, 那么就需要在多个节点之间同步Entity的信息;
如果是固定路由的话, 实际上就是内部分服的服务器, router将消息路由到固定的服上面去;
如果是一致性Hash, 那么当A节点坏掉了, 会将消息路由到B节点上面去, 从而实现一定程度的高可用性.
在随机分配这种策略里面, BigWorld是典型的代表. BigWorld将玩家实体分配到各个节点上去, 然后在剩余的节点内创建Shadow对象, 用来转发请求. 好处是服务器内部看起来是对称的, 有一定的扩展性, 坏处是扩展性不是那么强, 因为数据的同步有一定的成本(网络通讯), 然后Shadow对象有内存占用的成本. BigWorld这种模型, 可以用来做MMOG战斗.
固定路由策略, 典型的代表就是王者荣耀, 他内部实际上是分服的游戏.
一致性Hash这种策略, 比较难实现, 代表是Orleans. 微软用Orleans来做光环游戏的存档逻辑.
实际上BigWorld这种模型, 已经有一点接近于Actor编程模型. Actor编程模型天然支持分布式, 可扩展, 面相对象. 所以在横向扩展的游戏内, 大量的使用. 业内有不少游戏公司在使用Erlang做游戏服务器(广东那边), 就是因为Erlang支持actor模式. 云风的Skynet和微软的Orleans, 也是Actor模型.