« 放置类游戏的网络同步 | 返回首页 | Lua 中 Cache 冷数据的落地 »

继续谈网络游戏的同步问题

前面一篇谈了 放置类游戏的网络同步 ,我想把其方法推广到其它类型的游戏,比如 MMORPG ,比如动作游戏。尤其是动作类游戏,非常需要客户端可以即时处理玩家的操作,而不能等待服务器确认。

我们来看看这些类型的游戏和放置类游戏的不同点。

放置类游戏大部分是玩家个人和服务器在玩,不涉及第三方的干扰。所以,只要操作序列一致,那么结果就一致。

MMORPG MOBA 动作游戏这些,是多人在玩。如果我们能同步所有玩家的操作,让所有玩家的操作序列在一条线上,那么也一定可以保证结果一致。这点,是上篇 blog 的结论。

用同步操作的方式,同时赋予玩家对自己发起的操作标注时刻的权利,就可以在发起操作后,客户端立刻计算。只有在事后发生冲突时,服务器才会命令客户端取消无效操作。这个是前面谈及的同步框架可以解决好的问题。

但,MMORPG/MOBA 都可能有一个问题:并非所有玩家的操作以及操作修改的数据,都是针对战场上的每个人的。

比如,在 MMORPG 里,你可以选择隐身,别的玩家就不应该收到你隐身后的移动操作指令;你可以在战场上埋个地雷、别的玩家不应该事先知道你埋在了哪里。而且你未必希望让别人知道你背包里剩下什么道具,你还有多少 HP 。

而前面谈及的同步模型中,却必须要求全部初始状态为所有客户端及服务器都知晓,整个游戏过程中发生的任何事件都需要传达给任何人,最终才能保证结果的严格一致。

该怎么办呢?

我认为最简单的方法是建立两套模型,一个是针对个人的:personal model ;另一个是针对战场的:shared model ,所有人都可以见到。

对于服务器,应该有多个 personal model ,为每个客户端都建立有一套对应的同步 model ;而只有一份 shared model ,每个客户端都和服务器同步这个 model 。

从客户端的角度看,它有两个数据 model ,一个负责自身的状态,这些状态不会为别的玩家所见;另一个负责环境的状态,保持和其他玩家以及服务器一致。

每个操作,都应该注明是针对哪个 model 的。对于 personal model 的操作,服务器不会对其他玩家广播;对于 shared model 则在接收到玩家发起的操作后,广播给别的玩家。

这里处理的难点在于,操作本身是只能修改 model 内的状态的,禁止调用任何对 model 之外有影响的方法,禁止读取 model 之外的其它状态。只有避免副作用,才能保证一致性。但如果单个操作修改一个 model 的时候需要依赖另一个 model 内的数据怎么办呢?

一个通用原则是:在产生针对 A model 的操作指令时,读取 B model 的状态,把状态以参数的形式,打包在操作指令中。当这个操作指令从客户端传给服务器后,服务器需要做同样的计算,读取外部状态,这可能不仅仅是 B model ,可能还包括了其他玩家的 model 的影响,计算出参数。

然后比较两个参数是否一致。如果不一致,有两种处理方式:其一,通知客户端取消该操作;其二,如果偏差不大,先通知客户端取消该操作,然后在生成一个时间戳一样的新操作,但是附上服务器认可的参数传给客户端。

比如:玩家消耗 MP 给自己加了个加速移动 buf 。但游戏规则规定,如果玩家附近有敌对玩家,buf 效果会减弱。这时,有个敌对玩家隐身在他旁边,这个隐身玩家的位置信息并没有同步给他。如果该玩家不等服务器回应就要表现这个增益 buf ,并利用加速移动 buf 向前奔跑;那么就涉及之后位置不一致的情况。

对于施加加速移动 buf 这个行为,其实是针对玩家的 personal model 的,但需要获取环境(shared model )的影响。玩家可以在生成这个操作指令的时候,自行计算环境的影响,得到 buf 的等级(效果);而服务器收到玩家的操作命令时,也自行计算 buf 等级。当两者不一致时,服务器先取消掉玩家发送过来的操作指令,利用同样的时间戳生成一个带正确参数的操作命令发回给他即可。

玩家的客户端在自行处理这个 buf 后,随后收到了服务器的纠错,由同步模块内部的机制自动回滚和重新计算,可以到一致的 buf 等级。

服务器在处理完这个针对 personal model 的增益 buf 操作后,还需要针对场景的 shared model 发起一个该玩家对象被加速的操作;让场景中的所有人都知道这件事。接下来,玩家移动的操作,就是针对 shared model 的。场景中所有人都能通过同步到玩家的位置、速度、加速 buf 的等级,计算出后续时刻的位置了。


Shop Heroes 这种放置类游戏也有这类问题。

Shop Heroes 里有个公会的设定。玩家可以把自家的金币投资到公会的建筑上,建筑升级会给公会里所有的玩家一个短期的 buf 。比如,如果我投资公会的矿山,那么我自家的矿的单位时间产量会增加。

公会是很多玩家共有的。比如一个玩家可能和你同时投资,他的投资先生效有可能导致你的投资失败;也可能使你的投资获得更大收益(矿山等级更高 buf 效果更好)。这一切在服务器确认投资完成前你是不知道的。

Shop Heroes 的做法是,等待服务器回应后,玩家才真正看到效果,这也是涉及多人交互的游戏常规的做法。但对于个人操作体验来说不是特别好,尤其是网络条件比较差的情况。

如果采用以上提到的方法,就可以避免这个操作卡顿(等待服务器确认)。

玩家在客户端发起 “投资公会矿山” 这个操作时,生成一个针对 personal model 的操作指令,这个指令的参数里包括了给自己增加一个矿产量增加的 buf 级别(一级矿山对应一级增益 buf )。这个级别数是生成这个操作指令时,访问 shared model 也就是公会数据得到的。

玩家在把这个操作通知服务器的同时,还应该带上它针对服务器的 shared model 修改的版本基准。比如,它这个操作是针对公会 2 级矿山的。

之后客户端如果接着有收矿的操作,那么该收取多少矿石,这个增益 buf 是有效的。

服务器在收到这个操作请求时,应该先检验矿山等级基准是否一致,不一致可以驳回玩家的操作,但也可以保留。

例如:玩家认为矿山等级是 2 级,他的投资是针对 2 级矿山的;而这个时候同时有别的玩家抢先投资的矿山,导致矿山先被升到了 3 级;3 级矿山继续投资是需要更多金币的,玩家发起操作的时候并没有考虑投资更多的钱,这个时候就应该取消该玩家操作,让玩家的客户端接下来回滚。

但是,在玩家投资的同时,也可能有另一个同公会玩家退会,导致矿山降级(这是 Shop Heroes 的一个游戏规则),如果矿山被降为 1 级,其实玩家是可以用更少的金币投资的,这时,让操作继续可能是更体贴的做法;只是 buf 的效果也降低了。

还有另一种情况:玩家的投资基准没有变化,但投资结果不同。比如矿山原来是 2 级,玩家的投资不足以让矿山升级。但是有一个玩家同时投资了一笔钱,加上这笔投资,矿山恰好可以升到 3 级。那么投资产生的 buf 效果就是 3 级而不是 2 级。

接下来,服务器对自己的本地 personal model 应用这个操作。成功后(通常是在这里做一次金币数量检查,防止金币不够),应该由服务器重新计算当前矿山等级带来的增益 buf 等级,这可能和玩家自己认为的不一致。如果和玩家提交的参数一致,直接 pass ;不一致的话,应该通知客户端取消掉操作,并生成一个同时刻的,带有正确 buf 等级参数的指令发送给玩家。

最后,服务器再针对 shared model 生成一个投资矿山的操作指令,并广播给所有人。这样便完成了整个流程。

以上,讨论了“投资矿山”这个行为,即影响了玩家个人数据,又影响了公会数据,同时公会数据会作用于玩家个人数据变化。我们该如何处理这类问题,可以让客户端不依赖服务器确认,先行表现。

针对特定 model 的操作指令,一定要遵循不得访问该 model 之外的数据;而在个人数据和公会数据两个独立的 model 发生交互时,必须在构建操作指令前访问 model ,并以参数的形式打包在操作指令中。若服务器和客户端计算出不同的参数,服务器可通过取消客户端已做过的操作,生成同时刻的新操作来纠正客户端。


接下来谈另一个问题:

通常在查询 model 时,我们查到的是当前时间点,某个对象的状态。这对服务器来说足够了,但对客户端来说,需要根据状态显示一段动画就不太够了。客户端要承担 View 的责任,一切状态的改变,状态的保持,都是由动画表现的。如果不记住历史状态是什么,很难在让动画在时间轴上正确播放。如果仅仅是得到 Model 在一个时间点的快照,往往是不够的。

我们不能在操作 model 的指令函数中触发动画播放的函数,因为这违背了原则:指令函数只能访问对应 model 的数据,不得调用由任何副作用的函数。而且,为了保持严格一致,客户端和服务器应该使用严格一致的函数去操作 Model ,而服务器并没有显示部分,为服务器写一个 dummy 显示模块也显得多余。

我认为一个通用的手法是在 Model 中维护一个 event 列表,每次指令需要触发 Model 中的对象状态改变的时候,就把当前时间和事件记录在这个列表中。

比如,当 A 击中 B 的时候,可以在 event 列表中追加一条,在几点几分,B 被击倒。之后的事情,应该由 View 模块处理;它会在显示帧遍历 event 列表,读出没有处理过的 event 按时间戳显示对应的动画。

注意:View 只有读取 Model 数据的权利,不能改动它。所以处理完 event 后,event 会留在那里。如果 event 会维持一段时间,这段时间结束后,可能无人删除它。这就需要依赖时间戳忽略它,或是增加一个超时机制,再下次由指令添加 event 时,删除列表中超时的条目。

由于 Model 有可能被回滚重排指令,event 列表可能在很短的时间片内发生剧烈的变化。比如,客户端自己发起的动作在 event 列表中增加了一条将某个对象击倒的 event ,随之由于动作被服务器取消,这个条目可能就突然消失了,这需要客户端的 View 模块酌情处理。


修改 Model 的指令函数中是不能向指令队列追加指令的,这可能是一个较大的限制。因为,如果把指令函数看成是 Model 的修改器,而这个修改器行为同时存在于每个同步端点才保证了所有位置的 Model 数据一致。需要同步的正是这些指令行为。但每个动作指令都必须瞬时完成所有的修改操作。但你可能很想在做出一个动作后,延时再发起另一个动作指令。可在指令函数中向指令队列追加新指令时违反约定的(它访问了 Model 之外的东西,造成了副作用)。

解决这个问题的模式可以是这样:

在指令函数中想触发新指令时,把新指令极其时间戳写到 Model 中的一个 command 列表中。然后驱动它们的代码每帧检查这个列表,在时间临近前,把这个指令加入队列中(注意不要重复添加)。当这个新添加的指令执行时,应检查command 列表中是否有对应指令以及时间戳是否吻合,如果不吻合则抛出 error 。

这样,如果 Model 指令被重组,生成这条指令的行为被取消的话,被追加的指令也不会运行。

Comments

想自己开发一款小游戏,学到了

想自己开发一款小游戏,学到了

很有启发 不知低速网络下能否适用

云风大神也玩游戏?

云风大神,拜读了你的帖子,你们招人不?

看见和存在混淆了,你看不见地雷,但是有可能踩上去吧?

Post a comment

非这个主题相关的留言请到:留言本