联机游戏机制

导入

联机游戏和单机游戏其实区别很大,有不少开发者单机游戏开发熟练,但是接触联机游戏的时候还是无从下手,问题颇多,这里我就想写篇博客说下我的一些个人看法。

联机游戏机制

单机和联机的最本质区别自然就是一个需要联网一个不需要联网,这看起来是废话,但是背后带来的问题却是很多。联网意味着数据需要网络交互,就带来一个任何游戏玩家都知道的东西,ping或者叫延迟。

我们可以想象一个场景,假设我们在玩一个联机游戏,我本地按下方向键移动,然后我的输入会通过网络传输到服务器,接着服务器对我们的输入进行处理或者直接进行转发,这里是涉及到不同的同步策略,比如帧同步还是状态同步,但是这篇博客我就简单说下状态同步,同步策略并不重要,殊途同归。回到场景,服务器处理我们的移动输入,让角色对应移动,然后把角色新的位置发送回客户端,客户端对新的位置进行角色的渲染。这里大家应该都能发现一个问题,那就是网络延迟是一定会存在的,这是物理因素,不可抗,那么我们从按下方向键到角色的移动,就一定有一定时间的卡顿。但是你可能会奇怪,平时玩网络游戏也没卡顿,这是当然,如果有卡顿,那也没人玩网络游戏了。

这里就是关键,也是从单机游戏迈向联机游戏需要理解的最重要的一个理念。那就是联机游戏的服务器看到的和你客户端看到的是不一样的,但是联机游戏只要让你客户端看起来是流畅的就可以了,并不需要绝对同步,对于状态同步策略来说,你和其他的客户端的画面也可以是不一样的。只要自己玩起来流畅你就不会觉得有问题。

如何做到呢?这涉及到联机游戏的三个重要的机制概念,预测,和解,插值

预测

同样是刚才的场景,现在我按下方向键之后,我不想等服务器计算后返回位置,而是我自己本地就可以计算新的位置,然后立马就能渲染下一帧的新的角色位置的画面。这样我看起来不就不卡顿了。

这样很好,但是一个显然的问题就是,这样不是又变成单机游戏了吗。所以我们肯定还是需要服务器计算的状态的,尤其是对于C/S架构的联机游戏来说。所以我们既需要本地预测,也需要服务器的返回数据。这两个数据一样倒还好说,冲突了该怎么办?这就引出了第二个机制,和解

本地预测的简单示例代码:

def predict_movement(self, input_vec):
        # 利用本地当前位置render_pos和输入向量input_vec预测新的位置
        new_x = self.render_pos['x'] + input_vec[0] * 5
        new_y = self.render_pos['y'] + input_vec[1] * 5     
        self.render_pos['x'] = new_x
        self.render_pos['y'] = new_y

和解

所谓和解就是和解本地预测的数据和服务器返回的数据,当他们发生冲突的时候,需要把本地预测的数据纠正到服务器返回的权威状态。因为服务器返回的时候,本地可能已经预测了很多帧之后的数据了,所以需要做好同步。

def reconcile_states(self):
        if not self.server_states:
            return
        # 获取最新的服务器状态
        latest = self.server_states[-1]
        if latest['tick'] > self.last_confirmed_tick:
            server_entity = latest['state'].get(self.player_id)
            if server_entity:
                self.render_pos['x'] = server_entity[0]
                self.render_pos['y'] = server_entity[1]
            # 更新最新确认的tick时间
            self.last_confirmed_tick = latest['tick']
            # 取出最新服务器状态快照中的所有玩家状态{id:pos},用来绘制
            self.entities = latest['state']

这是简单的和解代码,和解策略就是如果和服务器不一致,就设置为服务器的权威状态。这是一种比较强硬的策略,但是对于大部分游戏都是效果很好,对于要求比较精准的游戏,比如FPS之类,更需要这种强硬的策略。而对于RPG,MOBA之类的要求没有那么精准的游戏,则可以稍微缓和,在本地预测状态和服务器权威状态之间进行缓和过渡。

经过预测+和解,可以得到下面这种效果:

如果仔细看的话,还是能够发现小球有轻微抖动的现象,这就是因为预测状态和服务器状态发生了冲突,导致小球的位置被“拉回”。

到此,预测+和解已经基本能够解决自身移动的平滑了,但是其他玩家的移动还是卡顿的,为什么呢?因为我们无法知道其他玩家的输入,因此无法预测(实际上可以少量外推其他玩家 的操作,可以在丢包时有更好的表现,但是属于特殊情况)。我们只能依赖服务器的返回数据来获取其他玩家的最新位置。而对于服务器来说,因为带宽的问题,同步的频率一般是远小于游戏帧率的。对于非FPS游戏来说,可能服务器的同步频率,专业叫做tick time可能只有15,20,也就是对于60FPS的游戏来说,每4帧才能获取一次其他玩家的位置,那么看起来自然是卡顿的。这就需要第三个机制插值来解决其他玩家的平滑问题。

插值

插值很好理解,就是在两次服务器返回的数据之间进行线性插值,达到平滑输出的目的。

def interpolate_entities(self):
        # 插值要求服务器状态快照队列中至少有2个快照才能插值
        if len(self.server_states) < 2:
            return
        t1 = self.server_states[-2]['tick']
        t2 = self.server_states[-1]['tick']
        if t1 == t2:
            return
        # 生成0~1之间的插值比例,根据时间戳计算插值比例
        alpha = min(1.0, max(0.0, (time.time()*1000 - t1) / (t2 - t1)))
        # 取出快照中所有玩家的状态,entity_id:玩家id entity:玩家位置
        for entity_id, entity in self.server_states[-1]['state'].items():
            if entity_id != self.player_id:
                prev_entity = self.server_states[-2]['state'].get(entity_id, entity)
                interp_x = prev_entity[0] + (entity[0] - prev_entity[0]) * alpha
                interp_y = prev_entity[1] + (entity[1] - prev_entity[1]) * alpha

                self.entities[entity_id] = [interp_x, interp_y]

这就是简单的插值代码,经过插值后,可以看到下面的效果:

右侧是玩家操作,左侧是其他客户端的视角,已经可以看到其他玩家移动的很平滑了。但是还是会发现,左侧的移动滞后于右侧,虽然很小,因为这是同一个延迟下,如果延迟相差较大,滞后感会更明显,但是就像前面说的,不同客户端之间的严格同步并不强求,只要每个客户端自己视角下都是流畅的就可以了。对于无冲突的联机游戏来说,也就是自身移动和其他玩家移动互不干扰的情况,这样就足够了,但是如果是有冲突的游戏呢?联机游戏大多还是有冲突的,如果FPS游戏中,你的视角人物已经躲进掩体,但是别人的视角你还在躲进掩体的路上,那就会出问题。解决方法一是更高的服务器tick time,一般FPS游戏的同步频率都是100以上,二就是另一个联机游戏中的重要机制,延迟补偿。服务器计算子弹命中等碰撞检测时,并不会按照当前时间下的角色位置计算,而是根据角色的延迟,使用延迟时间之前的位置进行计算,这样即使在高延迟的情况下,依然可以做到不影响游戏体验。比如APEX游戏,使用延迟补偿技术,即使是超过200的延迟对射击也不会有太大的影响。

可以看到服务器端使用了碰撞检测和延迟补偿之后,不同客户端之间的命中判断基本是一致的。

总结

联机游戏的三种重要机制,预测,和解,插值,其中预测和和解目的是使得玩家自身流畅,而插值目的是使得其他玩家平滑。而延迟补偿技术可以进一步提高高延迟下的游戏体验。我考虑找一个现成的单机游戏,直接改成联机游戏,然后在下一篇博客中再向大家介绍,希望可以进一步提升大家的理解。