游戏循环
游戏循环是一种典型的游戏编程范式,在游戏之外的领域很少用到。
客户端游戏循环
首先来看客户端的游戏循环,伪代码如下:
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 是一个事件中心:
register() 注册监听事件,参数是事件类型和事件处理函数。
unregister() 解除注册。
post() 发布事件。
handlerMap 记录了事件对应的处理函数列表。处理函数可以有优先级。
示例
以《炉石传说》为例,游戏中有以下卡牌:
扭曲巨龙泽拉库:每当你的英雄受到伤害,召唤一条6/6的虚空幼龙。
以眼还眼:当你的英雄受到伤害时,对敌方英雄造成等量伤害。
后院保镖:每当一个友方随从死亡,便获得+1 攻击力。
诅咒教派领袖:在一个友方随从死亡后,抽一张牌。
毒镖陷阱:在对方使用英雄技能后,随机对一个敌人造成 5点伤害。
用事件驱动的方式,可以将“英雄受到伤害”、“随从死亡”、“使用英雄技能”等定义成事件,卡牌上场时注册对应事件,离场时解除注册。这样,当新加一种卡牌时,不需要修改原有代码,只需要在新卡牌的代码中注册事件,最终实现了事件发布者和订阅者的解耦。
帧同步 状态同步
同步
所谓同步,就是要多个客户端表现效果是一致的。实现同步有帧同步和状态同步两种方式。
- 帧同步:服务端只转发客户端操作指令,逻辑计算在客户端。
适合玩家少且固定,单局时间短,对一致性要求高的游戏。 - 状态同步:服务端做逻辑计算,并将计算结果和新的游戏状态同步到客户端。
适应性更广,尤其是玩家数量多,游戏复杂度高的游戏。
帧同步实现方式
- 游戏开始时服务器下发随机种子
- 客户端使用同样的浮点数算法
- 客户端上传玩家操作到服务器,服务器将操作和帧序列广播给所有客户端
- 若一帧没有玩家操作,服务器广播空帧,用于推动客户端运行
- 客户端根据收到的操作指令做逻辑计算
- 客户端表现使用预测和插值计算(如王者荣耀同步每秒15帧,渲染每秒30/60帧)
- 多使用UDP协议
- 回放只需要保存操作序列,按操作序列重新执行一遍即可
- 断线重连需要从第一帧重新执行一遍
- 外挂检测采用投票或服务器验证的方式,但不能防全图挂
状态同步实现方式
(以魔兽世界为例)
- 玩家使用技能
- 客户端播放技能动作,同时将指令上传到服务器
- 服务器验证能否使用技能,并广播技能开始
- 其他客户端开始播放技能动作
- 服务器持续计算技能结果,并广播结果
- 客户端根据接收到的结果和动作节点,播放动画、特效等
- 若技能改变了游戏状态,广播新的状态
帧同步 | 状态同步 | |
---|---|---|
开发难度 | 低 | 高 |
安全性 | 低 | 高 |
一致性 | 高 | 低 |
响应性 | 低 | 高 |
服务器压力 | 低 | 高 |
断线重连 | 困难 | 容易 |
回放 | 容易 | 困难 |
跨平台 | 困难 | 容易 |
游戏案例
天梯: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) 的随机采样算法。
场景AOI算法
AOI(Area Of Interest),即感兴趣区域,指玩家在场景中的视野区域。
AOI的统一接口如下:
- 角色(玩家、怪物等)拥有坐标和视野半径
- 角色行为有进入场景、离开场景、移动
- 其他角色进、出自己的AOI时,接受通知
- 获取AOI范围内角色集合
有了AOI之后,场景同步可以只同步AOI内的角色行为。AI感知也可以基于AOI实现。
注意:帧同步必须使用全场景同步。
网格法
将场景划分为一个个的小格子,每个格子记录处于其中的角色,我周围的格子里的角色,即为AOI兴趣角色。
网格法不适用于人数很多、场景很大的情况。
十字链表法
- 建立两个双向链表,场景中所有角色按x坐标排序,存到第一个链表中,按y坐标排序,存到第二个链表中。
- 角色移动时,根据新坐标调整在两个链表中的次序,保证链表是有序的。
- 有新角色进入场景时,插入到链表合适的位置。
脏标记
MMO游戏的场景中有许多角色,会频繁产生大量的属性变化,如果每次属性变化都进行同步则开销太大。而使用脏标记模式,在属性变化时将属性的标志位标脏,周期性的将标脏的属性同步给所有客户端,这样可以很大程度上减少同步量。
同样,将变化的属性记录到Redis缓存或数据库中,也可以使用脏标记模式。
脏标记可以用位来记录,这样一个long可以记录64个脏标记。也可以用set记录。
AI:状态机、行为树
AI在游戏中有大量应用,比如人机模式的战斗AI、怪物AI、玩家挂机AI等。
AI的实现一般有状态机和行为树两种方式。
状态机
状态机是一种表示状态并控制状态切换的设计模式。角色处于某个状态中,状态包含数据以及事件处理逻辑,当一个事件发生时,会触发一个动作,或者执行一次状态的迁移。
但是状态机有一些缺陷:
- 各个状态类之间互相依赖很严重,耦合度很高
- 结构不灵活,可扩展性不高,难以脚本化/可视化
- 状态的可复用性差
而行为树可以很好地解决这几个问题。
行为树
行为树是一棵用于控制 AI 决策行为的、包含了层级节点的树结构。树的最末端——叶子,就是这些 AI 实际上去做事情的命令;连接树叶的树枝,就是各种类型的节点,这些节点决定了 AI 如何从树的顶端根据不同的情况,来沿着不同的路径来到最终的叶子这一过程。
Unity 和 Unreal 引擎都内置了可视化的行为树框架。
深度学习
深度学习也可以用于游戏AI,但目前使用不广泛,用到的有《王者荣耀》、《逆水寒》
、《星际争霸2》等,而且也是小规模应用。原因有:
- 深度学习可以做出特别强大、灵活多样的战斗AI。而游戏AI往往需要有特定的模式、玩家可以学习并战胜
- 需要庞大的计算量,难以大规模应用
- 结果可控性差,开发人员难以调节
- 较高的开发、维护成本
AC自动机:一种多模匹配算法
可以理解为Trie树+KMP算法。
一个常见的应用场景就是:给出n个单词,再给出一段包含m个字符的文章,找出有多少个单词在文章里出现过。
在游戏中常用于屏蔽词过滤。
使用UDP协议
和UDP相比,TCP协议有以下特性:
- 面向连接,也就是说有建立连接和断开连接两个行为
- 保证了消息的顺序性
- 超时重传
- 拥塞控制,通过滑动窗口和指数退避实现
但是,TCP也存在一些问题:
- 移动网络质量较差,经常遇到信号不可用、或者在Wifi和移动网络间切换的情况,导致TCP连接断开
- TCP确认机制,导致延迟时间高于UDP
使用UDP提供可靠传输
- 为消息添加一个序列号,发送方按顺序发送,接收方通过序列号判断消息顺序以及是否有丢失
- 加入类似tcp的确认机制,让发送方知道哪些包发送成功,可以有不同的实现方式
- 发送方缓存消息,用于失败重发
- 有些特定的消息失败不需要重发,比如世界聊天、移动消息,可以用单独的序列号,自行控制重发机制
- 检测连接接入和断开,以及客户端IP变化,用于登录认证
- 单个包大小要小于MTU
- 如果网络负载较小,可以不实现拥塞控制