前面一篇谈了 放置类游戏的网络同步 ,我想把其方法推广到其它类型的游戏,比如 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 ,并以参数的形式打包在操作指令中。若服务器和客户端计算出不同的参数,服务器可通过取消客户端已做过的操作,生成同时刻的新操作来纠正客户端。