游戏服务器设计模式及算法

本文介绍了游戏编程中的核心概念,包括客户端和服务器的游戏循环,探讨了不同实现方式及其优缺点。同时,详细阐述了事件驱动的原理和应用,以及在《炉石传说》中的实例。此外,还对比了帧同步和状态同步在游戏中的适用场景和技术挑战。最后,讨论了游戏中的同步策略,如脏标记、AI决策系统和网络协议选择,为游戏开发提供了深入的理解。
摘要由CSDN通过智能技术生成

游戏循环

游戏循环是一种典型的游戏编程范式,在游戏之外的领域很少用到。

客户端游戏循环

首先来看客户端的游戏循环,伪代码如下:

while (true) {
    processInput()  // 处理(用户、网络)输入
    update()  // 更新游戏状态
    render()  // 渲染
}

有两个相关的术语:

  • 游戏速度:游戏状态每秒更新的次数,即每秒调用 update() 的次数。
  • FPS:Frames Per Second,每秒调用 render() 的次数。高性能的硬件支持更大的FPS。

具体实现有以下四种方式:

  • FPS和游戏速度相等,两者均恒定
  • FPS和游戏速度相等,两者均可变
  • 游戏速度为最大FPS,游戏速度恒定,FPS可变
  • 游戏速度恒定,FPS可变,渲染使用差值计算

服务器游戏循环

服务器游戏循环和客户端类似,但是没有渲染环节:

while (true) {
    processInput()  // 处理(网络)输入
    update()  // 更新游戏状态,包括定时器
}

最简单的固定帧率实现:

int MS_PER_TICK = 100  //每帧100ms

while (true) {
    long start = now()
    processInput()
    update()
    long elapsed = now() - start  //流逝的时间
    if (elapsed < MS_PER_TICK) {
        sleep(MS_PER_TICK - elapsed)
    }
}

但是有一个问题,如果一帧消耗的时间大于100ms,会导致计时不精确。

有两种解决方式,第一种是追帧:

long nextTickTime = now()
while (true) {
    processInput()
    update()
    nextTickTime += MS_PER_TICK
    long now = now()
    if (nextTickTime > now) {
        sleep(nextTickTime - now)
    }
}

另一种是不固定帧率,update()传入两帧间隔时间:

long lastUpdate = now()
while (true) {
    long start = now()
    processInput()
    update(start - lastUpdate)
    lastUpdate = start
    long elapsed = now() - start
    if (elapsed < MS_PER_TICK) {
        sleep(MS_PER_TICK - elapsed)
    }
}

这样就能保证精确计时了。和追帧的方式相比,不固定帧率可以减缓服务器负载。

还有另一个问题,上述实现每帧调用一次 processInput(),导致处理用户请求有最多100ms的延迟。改为实时处理用户请求:

long lastUpdate = now()
while (true) {
    long start = now()
    update(start - lastUpdate)
    lastUpdate = start
    processInput()   //处理已经接收的用户请求
    long elapsed = now() - start
    while (elapsed < MS_PER_TICK) {
        Object input = waitInput(MS_PER_TICK - elapsed) //等待用户请求
        processInput(input);
        elapsed = now() - start
    }
}

其中 waitInput() 为等待用户请求,阻塞调用,参数为最大阻塞时间

事件驱动

原理

实现事件发布者和订阅者的解耦。下图 EventBus 是一个事件中心:

EventBus -handlerMap +register(eventType, handler) +unregister(eventType, handler) +post(event)

register() 注册监听事件,参数是事件类型和事件处理函数。
unregister() 解除注册。
post() 发布事件。
handlerMap 记录了事件对应的处理函数列表。处理函数可以有优先级。

示例

以《炉石传说》为例,游戏中有以下卡牌:

扭曲巨龙泽拉库:每当你的英雄受到伤害,召唤一条6/6的虚空幼龙。
以眼还眼:当你的英雄受到伤害时,对敌方英雄造成等量伤害。
后院保镖:每当一个友方随从死亡,便获得+1 攻击力。
诅咒教派领袖:在一个友方随从死亡后,抽一张牌。
毒镖陷阱:在对方使用英雄技能后,随机对一个敌人造成 5点伤害。

用事件驱动的方式,可以将“英雄受到伤害”、“随从死亡”、“使用英雄技能”等定义成事件,卡牌上场时注册对应事件,离场时解除注册。这样,当新加一种卡牌时,不需要修改原有代码,只需要在新卡牌的代码中注册事件,最终实现了事件发布者和订阅者的解耦。

帧同步 状态同步

同步

所谓同步,就是要多个客户端表现效果是一致的。实现同步有帧同步和状态同步两种方式。

  • 帧同步:服务端只转发客户端操作指令,逻辑计算在客户端。
    适合玩家少且固定,单局时间短,对一致性要求高的游戏。
  • 状态同步:服务端做逻辑计算,并将计算结果和新的游戏状态同步到客户端。
    适应性更广,尤其是玩家数量多,游戏复杂度高的游戏。

帧同步实现方式

  1. 游戏开始时服务器下发随机种子
  2. 客户端使用同样的浮点数算法
  3. 客户端上传玩家操作到服务器,服务器将操作和帧序列广播给所有客户端
  4. 若一帧没有玩家操作,服务器广播空帧,用于推动客户端运行
  5. 客户端根据收到的操作指令做逻辑计算
  6. 客户端表现使用预测和插值计算(如王者荣耀同步每秒15帧,渲染每秒30/60帧)
  7. 多使用UDP协议
  8. 回放只需要保存操作序列,按操作序列重新执行一遍即可
  9. 断线重连需要从第一帧重新执行一遍
  10. 外挂检测采用投票或服务器验证的方式,但不能防全图挂

状态同步实现方式

(以魔兽世界为例)

  1. 玩家使用技能
  2. 客户端播放技能动作,同时将指令上传到服务器
  3. 服务器验证能否使用技能,并广播技能开始
  4. 其他客户端开始播放技能动作
  5. 服务器持续计算技能结果,并广播结果
  6. 客户端根据接收到的结果和动作节点,播放动画、特效等
  7. 若技能改变了游戏状态,广播新的状态
帧同步状态同步
开发难度
安全性
一致性
响应性
服务器压力
断线重连困难容易
回放容易困难
跨平台困难容易

游戏案例

在这里插入图片描述

天梯:ELO等级分

ELO等级分是指由物理学家Elo创建的一个衡量各类对弈活动水平的评价方法,是当今对弈水平评估的公认的权威方法。被广泛用于国际象棋、足球、游戏天梯排名等。

假设A和B的等级分为RA和RB,则按Logistic distribution, A对B的胜率为:
在这里插入图片描述

假如一位棋手在比赛中的真实得分SA(胜=1分,和=0.5分,负=0分)和他的胜率期望值不同,则他的等级分要作相应的调整。具体的数学公式为:
在这里插入图片描述
K值常规比赛通常为32,大师级比赛通常为16。

例如,A等级分为1613,与等级分为1573的B战平。若K取32,则A的胜率期望值为
在这里插入图片描述
因而A的新等级分为
在这里插入图片描述

Alias Method 随机采样算法

一种时间复杂度为 O(1) 的随机采样算法。

Alias Method离散分布随机取样

场景AOI算法

AOI(Area Of Interest),即感兴趣区域,指玩家在场景中的视野区域。

AOI的统一接口如下:

  • 角色(玩家、怪物等)拥有坐标和视野半径
  • 角色行为有进入场景、离开场景、移动
  • 其他角色进、出自己的AOI时,接受通知
  • 获取AOI范围内角色集合

有了AOI之后,场景同步可以只同步AOI内的角色行为。AI感知也可以基于AOI实现。

注意:帧同步必须使用全场景同步。

网格法

将场景划分为一个个的小格子,每个格子记录处于其中的角色,我周围的格子里的角色,即为AOI兴趣角色。

网格法不适用于人数很多、场景很大的情况。

网格法图解

十字链表法

  1. 建立两个双向链表,场景中所有角色按x坐标排序,存到第一个链表中,按y坐标排序,存到第二个链表中。
  2. 角色移动时,根据新坐标调整在两个链表中的次序,保证链表是有序的。
  3. 有新角色进入场景时,插入到链表合适的位置。

KBEngine 中的十字链表实现

脏标记

MMO游戏的场景中有许多角色,会频繁产生大量的属性变化,如果每次属性变化都进行同步则开销太大。而使用脏标记模式,在属性变化时将属性的标志位标脏,周期性的将标脏的属性同步给所有客户端,这样可以很大程度上减少同步量。

同样,将变化的属性记录到Redis缓存或数据库中,也可以使用脏标记模式。

脏标记可以用位来记录,这样一个long可以记录64个脏标记。也可以用set记录。

AI:状态机、行为树

AI在游戏中有大量应用,比如人机模式的战斗AI、怪物AI、玩家挂机AI等。

AI的实现一般有状态机和行为树两种方式。

状态机

状态机是一种表示状态并控制状态切换的设计模式。角色处于某个状态中,状态包含数据以及事件处理逻辑,当一个事件发生时,会触发一个动作,或者执行一次状态的迁移。

但是状态机有一些缺陷:

  • 各个状态类之间互相依赖很严重,耦合度很高
  • 结构不灵活,可扩展性不高,难以脚本化/可视化
  • 状态的可复用性差

而行为树可以很好地解决这几个问题。

行为树

行为树是一棵用于控制 AI 决策行为的、包含了层级节点的树结构。树的最末端——叶子,就是这些 AI 实际上去做事情的命令;连接树叶的树枝,就是各种类型的节点,这些节点决定了 AI 如何从树的顶端根据不同的情况,来沿着不同的路径来到最终的叶子这一过程。

Unity 和 Unreal 引擎都内置了可视化的行为树框架。

AI 行为树的工作原理

Unity 行为树

深度学习

深度学习也可以用于游戏AI,但目前使用不广泛,用到的有《王者荣耀》、《逆水寒》
、《星际争霸2》等,而且也是小规模应用。原因有:

  • 深度学习可以做出特别强大、灵活多样的战斗AI。而游戏AI往往需要有特定的模式、玩家可以学习并战胜
  • 需要庞大的计算量,难以大规模应用
  • 结果可控性差,开发人员难以调节
  • 较高的开发、维护成本

网易游戏——伏羲人工智能实验室

AC自动机:一种多模匹配算法

可以理解为Trie树+KMP算法。

一个常见的应用场景就是:给出n个单词,再给出一段包含m个字符的文章,找出有多少个单词在文章里出现过。

在游戏中常用于屏蔽词过滤。

AC自动机算法图解

使用UDP协议

和UDP相比,TCP协议有以下特性:

  • 面向连接,也就是说有建立连接和断开连接两个行为
  • 保证了消息的顺序性
  • 超时重传
  • 拥塞控制,通过滑动窗口和指数退避实现

但是,TCP也存在一些问题:

  • 移动网络质量较差,经常遇到信号不可用、或者在Wifi和移动网络间切换的情况,导致TCP连接断开
  • TCP确认机制,导致延迟时间高于UDP

使用UDP提供可靠传输

  • 为消息添加一个序列号,发送方按顺序发送,接收方通过序列号判断消息顺序以及是否有丢失
  • 加入类似tcp的确认机制,让发送方知道哪些包发送成功,可以有不同的实现方式
  • 发送方缓存消息,用于失败重发
  • 有些特定的消息失败不需要重发,比如世界聊天、移动消息,可以用单独的序列号,自行控制重发机制
  • 检测连接接入和断开,以及客户端IP变化,用于登录认证
  • 单个包大小要小于MTU
  • 如果网络负载较小,可以不实现拥塞控制
  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值