« September 2016 | Main | November 2016 »

October 17, 2016

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

前面一篇谈了 放置类游戏的网络同步 ,我想把其方法推广到其它类型的游戏,比如 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 指令被重组,生成这条指令的行为被取消的话,被追加的指令也不会运行。

October 12, 2016

放置类游戏的网络同步

最近想试着做一款类似 Shop Heroes 的放置类网络游戏。总结一下此类游戏的客户端服务器同步问题。

传统放置类游戏,比如小黑屋,是单机运行,不需要和服务器同步的。但出于防止作弊的目的(作弊会使玩家迅速失去游戏乐趣)或者希望加上多人玩法,我们通常希望把游戏进程放在服务器上管理,这样就有了网络同步问题。

加上了服务器后,我们依然想保持玩家单机游戏的流畅体验,这里该怎么做,还是有许多门道的。


对于 COC 类的城市建设那部分,我们可以看成是一个简单的放置类游戏:等待、收集资源、升级建筑。无论你在线还是离线,游戏都一直保持向前的进程。

类 COC 的建设在玩家在线时,是完全不受干扰的。也就是说,对于城市对象来说,只有一个更改者。所以最简单的做法是,在玩家登录的时候,把上次离线时的状态从服务器同步到客户端,然后计算离开的这段时间发生的变化,得到现在的状态。而在玩家在线期间,玩家所做的每步操作,在完成相应逻辑后,都带上时间戳发送(同步)给服务器。服务器上有一套完全一样的模块,按时间戳(而非接收到操作的时间)进行完全一致的操作。

这样,客户端所做的任何操作都不需要等待服务器的确认,和单机玩游戏有一模一样的操作手感。而服务器则仅仅做操作的合法性校验,一旦不合法,比如花一定金币去升级建筑,但是钱的数额不够,就要求客户端重新登录(正常游戏过程是不会发生的)。玩家下线也不需要将状态同步给服务器,服务器用户以它复现之前玩家操作得到的结果为准。

在实现这个模型的时候,我们可以把玩家的城市抽象成一组数据,例如金币的总数量、建筑的位置和级别、正在升级的建筑开始结束时间、矿场最后一次收集的时刻、等等。

这组数据在客户端可用于视觉呈现、在服务器可用于校验。两边有一致的数据结构,接受同样规范的操作消息。如果输入的操作是完全相同的,可以认为在同一个逻辑时刻,两边的数据结构是完全一致的。

区别只在于,客户端的进度略比服务器块,这是由网络延迟造成的。我们不比强求在真实时刻,两边的数据完全同步。只有同一个玩家,用严格有序的操作去改变数据,那么只要操作最终同步到,总能保持一致。玩家只可能在离线前丢失最后几个操作,一旦再次上线,会从服务器同步回完整的状态。

COC 的这个模型实现起来最为简单,他把玩家间可能发送相互干扰的操作放在了玩家离线后,避免了对城市改变序列的不一致问题。


Shop Heroes 的模型要复杂一些。

在 Shop Heroes 里,会影响你的操作结果的不再仅仅是一个玩家了,它增加了多个玩家共建一个城市,而城市建筑的等级以及别人的投资会影响玩家的操作结果。

以收矿为例,玩家家中的资源桶的生产效率其实是由城市里矿山的等级、以及是否有玩家对矿山进行投资决定的。

公会别的玩家投资升级了矿山、或是公会中有玩家退会导致了矿山降级,都会影响当前的矿桶的生产速度。而这些事件一定会因为网路延迟而先发生后知晓。所以、玩家进行收矿这个操作的结果,对矿池的影响,客户端和服务器的结果很可能是不一致的。

比如我们在假象中放大网络延迟,假设玩家的矿桶在 8 点的时候被清空,而他在 9 点的时候点击矿桶,这个操作可以把这一个小时中的产出转移到自己的矿池内。一个小时的产出是由矿山级别决定的,假设当前级别下,每小时可以产出 20 个矿石,那么玩家会在操作后认为自己获得了 20 个矿石,并把矿桶清空。

但是,如果在 8 点半的时候,同公会另一个玩法退会,导致了矿山降级,结果影响了矿山产量,可能这个小时就只产生了 19 个矿石。如果这个退会消息在 9 点前发送给了玩家,只要这个消息带有时间戳,玩家依旧可以准确计算出这个小时的矿石产量。但如果该消息的推送延迟了半小时以上才送达,服务器和客户端就不同步了。

现实中,网络延迟并不会这么长,充其量也就是几秒,而漏掉几秒内的矿山变化的影响对当此收取多少矿石的数量影响是微乎其微的。如果你在规则上忽略这个差异,似乎也没太大问题。比如,你可以在规则上允许玩家的矿石存量可以为负数,只要不负得太多。那么即使他在收取了 20 个矿石后,立刻制造了一件需要 20 矿石的装备;而服务器认为他只有 19 个矿石,但是欠一个是允许的,玩家依然可以流畅的游戏。

如果实现得当,这个误差是不会累计的。但这个实现可能要稍微复杂一点。

在上个例子里,由于网络延迟,客户端收到的操作是先有采集矿,再有矿山变化;而服务器则是先收入矿山变化,然后才是采集矿石。每个操作都带有时间戳,理论是存在唯一的作用次序。无论客户端还是服务器都有乱序的可能。所以计算模块必须有纠正次序的能力。

即,如果你收到一个操作带有的时间戳是早于已经发生作用的操作的,必须把已经发生的操作撤回,按正确的次序先自行刚收到的更早的这个操作,并依次执行后续提前收到的操作。如果两侧都能保证这点,那么状态依旧还是严格一致的。

对于服务器来说,收到无法回插的操作(延迟太高),它可以拒绝掉这个操作,命令客户端重新登录同步全状态;对于客户端,如果收到了太早以前的操作无法回溯,它也可以简单的重新登录同步。两端的实现不必有任何差别。

乱序操作的重排,不可以影响操作的结果。比如因为重排了一个操作,导致原来一个制造操作的原料不够而制造无法进行是不可以的。解决方法有二,一是拒绝掉插入的操作,二是让操作的限制可以容错,比如可以欠一些原料但依旧执行。

客户端的乱序重排有可能影响到游戏体验:从来眼前发生的事情导致的结果突然变了。但大部分重排可能没有那么大的影响。比如升级一个铁矿山和制造一件装备其实是不相关的,重新调整次序也不会有影响。


如果多人交互的游戏里冲突过大,且相互经常造成影响,从而导致客户端重新调整操作次序对操作感影响太大怎么办?

方法有二:

最简单粗暴的方法是,客户端发起操作的时间戳是由服务器给出的,即,操作时刻以服务器为准。这样服务器端永远不会由时序错乱,同时,服务器将一个玩家的操作同步给其他玩家时,推送的次序也永远不会乱序。

但是这么做的代价就是,客户端自己的操作也必须等服务器回应(获得真正发生的时刻,或者得知操作无效)才可以修改自身的状态,继而表现。大多数传统 MMO 就是这么做的,也就是所谓的一切操作由服务器确认。如果网络条件不好的时候,就会让玩家感觉很卡。

Shop Heroes 应该就是这么做的:网络不好的时候,你会发现矿桶里的矿收不下来、给城市建筑捐助无法生效。

第二个方法稍微复杂一点,可以结合上面的策略,时间戳由客户端给出。但是,你可以充分考虑网络延迟,比如所有的操作时间戳都比当前时间延后一两秒。相当于同步操作都提前 1,2 秒发出。

某些操作是由服务器计算决定的,比如你打怪的掉落品,转轮盘的奖品,都可以看成是服务器单独发起的操作,服务器按一样的方法,将发生的时间点推迟一点即可。

无论哪端,在收到消息后,如果时间戳的时间未到,都暂时不处理,待到时间到了,一些乱序的操作可以在执行前重新排列成正确的次序。这样可以大大的减少事后发现要回溯的可能性。


总结:

这里讨论的其实是一个通用的同步方案,并不局限于放置类游戏。

我认为对于多人由交互的游戏,体验最好的方案是:

客户端和服务器使用完全相同的数据模型。

每个客户端发送任何操作的时候,都带上这个操作发生的时间戳。这个时间点可以是当前时刻,也可以略往后推迟,推迟时长可以根据游戏具体设计来设定。对于不太会影响别人,也受别人影响的操作,推延时间可以是 0 ;非常容易相互冲突的操作,时长可以长到秒级。而客户端在较长操作延迟时做出进度条提示。

服务器负责在收到任何操作时,将之(如果有可能对他人造成影响)同步给其他人。

无论是服务器还是客户端,都有责任在操作中时间戳到来的时刻执行这步操作。操作执行必须按照严格的时序。如果在执行某个操作时,之前已经执行了更晚时间发生的操作,需要将已执行的操作回退,插入新收到的操作,严格按时序重新执行。

如果操作重排无法执行,对于服务器,应当丢弃该操作,通知发起者该操作无效。如果之前已经将操作广播给了其它客户端,那么还需要通知其它所有客户端取消掉该操作;对于客户端,可以放弃重排该操作,然后和服务器重新同步状态(简单的办法就是重新登录)。后面这个可以推迟一小段时间再执行(因为有可能接下来服务器可能会取消它)。


如果想把这个同步方案推广到 MOBA/RTS 类的游戏,还可以多做一些工作。

因为在这类游戏中,一旦发生客户端按操作时序重新计算状态,可能让玩家感觉到环境跳变,体验不好。所以我们可以在客户端保存两套数据模型。

一套是严格服务器同步的状态,由于和服务器以及其他玩家严格执行完全一致的操作序列,那么一定是保持严格一致的。

但这套数据模型因为有可能因为同步原因,需要重新计算状态,会引起状态的跳变。为了避免玩家操作上的步适,可以建立第二套模型。第二套模型和第一套模型的初始状态是一致的,在第一套状态变化后,和第二套做插值,计算出一个变更比较小的结果作用在第二套上。减少玩家的跳变感受。

比如一个 RTS 游戏中,玩家操作一个角色全速向目标点移动,只要知道速度和出发时刻以及路径,就可以计算出每个时刻的位置。无论在客户端还是服务器,位置都是一致的。

但如果这时有另一个玩家对这个敌对角色释放了一个减速魔法,而该消息由于网络延迟而晚到,为了保持一致的状态,客户端就需要重新计算整个过程,把曾经发生过的减速法术的影响因素考虑进去。如果直接在收到迟到的减速魔法消息,重新计算后立刻刷新表现,该角色就会看起来突然被拉了回去。所以,我们可以把修改缓慢的作用在第二套用户呈现的数据模型上,一帧只拉回去一点点,或是移动慢一点点;每帧都对两个模型做插值调整,就可以让呈现的数据模型慢慢吻合。


ps. 随手写了个 lua 版本展示一下这个概念:https://github.com/cloudwu/syncmodel

10 月 17 日补充:

在实现的过程中,我想到了一些细节,实现代码也修改了几次。为了方便客户端和服务器使用这个同步模块,我做了一些约定,并明确了服务器和客户端的职责。

服务器在收集所有客户端指令时,允许事件的乱序,但一旦向客户端广播指令后,就不再撤销。这样可以方便客户端处理。客户端只会收到服务器撤销自己之前发出的指令,而收到的别的客户端转发过来的指令,是不会撤销的。

服务器不必有一个严格意义上的当前时间,它只是收集各个客户端发送过来的指令,按时间戳排序。服务器可以拒绝掉比当前真实时间晚或早太多的事件;也就是说,客户端可以考虑一点网络延迟因素而发送未来很短时间后的事件,但不可以提前预定太久以后的事件。

服务器在收到的指令比之前收到的所有指令都晚时,直接把新指令加到队列后面;如果比队列中某些指令略早,则回滚后按新次序重新执行。一旦执行的过程中有异常,删除新收到的指令,保留收到前的状态。换句话说,新送达的指令永远比后送达的指令优先级高。

服务器用最大缓存指令条数来限制回滚历史。当一条新指令到达需要插入指令队列最前,而队列已经超长,可直接拒绝。

客户端在登录后,应该从服务器处获取当前的时间和状态,以此来创建本地对象。

客户端可以将自己发起的指令先置入本地队列,然后发送给服务器;之后,客户端可以将本地操作指令和服务器转发来的外部指令放在一起,按时间戳排序。

客户端应根据真实时间推演,计算出某个时刻的状态。如果自己发起的指令和后续收到的外部指令冲突导致无法计算出合法状态期间,客户端应该耐心等待服务器陆续发来的指令,直到服务器命令其删除之前的无效指令。注:自己发起的指令可能被服务器判为无效,而收到一条删除请求;客户端收到的外部指令一定是有效的。

如果长时间无法计算出一个合法状态,应该重新和服务器做状态同步(最简单的处理方法是重新登录)。

由于只能获得模型的数值快照,那么如何表达一个持续发生的状态呢?

比如 A 打了 B ,这个行为是一个命令,但是 A 打 B 是一个过程,在客户端是需要表现的。而如果这个过程无法通过数值模型表达的话,客户端的 View 部分很难正确显示。因为在任何一个瞬间的快照中,都只有每个对象的状态,而不能表达它身上曾经发生过什么事情。

假设 A 打了 B 的命令由于某种原因被取消(比如这个命令是客户端自己发起的,事后服务器认为其实 B 已经提前走开),那么从快照很难修复这个表现偏差;又或者,C 是后来登录的,他并没有经历 A 打 B 这个事件的时间点。

所以我建议在数据模型里加一个 event 列表。命令本身可以给 event 列表增加事件(包括发生的时间)。这个列表只通过命令集生成,只在超过上限的时候删除老的事件。正常流程中,事件列表不必通过网络同步,但会因为大家执行了共同的命令序列而有一致的数据。

客户端的 View 模块只需要每帧去遍历这个事件列表,就可以知道现在要显示什么动画了。