最近想试着做一款类似 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 游戏中,玩家操作一个角色全速向目标点移动,只要知道速度和出发时刻以及路径,就可以计算出每个时刻的位置。无论在客户端还是服务器,位置都是一致的。
但如果这时有另一个玩家对这个敌对角色释放了一个减速魔法,而该消息由于网络延迟而晚到,为了保持一致的状态,客户端就需要重新计算整个过程,把曾经发生过的减速法术的影响因素考虑进去。如果直接在收到迟到的减速魔法消息,重新计算后立刻刷新表现,该角色就会看起来突然被拉了回去。所以,我们可以把修改缓慢的作用在第二套用户呈现的数据模型上,一帧只拉回去一点点,或是移动慢一点点;每帧都对两个模型做插值调整,就可以让呈现的数据模型慢慢吻合。