« ephemeron table 对 property tables 的意义 | 返回首页 | 继续谈网络游戏的同步问题 »

放置类游戏的网络同步

最近想试着做一款类似 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 模块只需要每帧去遍历这个事件列表,就可以知道现在要显示什么动画了。

Comments

其实放置类游戏不需要这么麻烦, 需要客户端服务端单独运算一般在fps比较常见,毕竟延迟过久会影响战局的。

而对于放置类游戏,玩家的操作慢个一两秒没什么关系,客户端可以直接只做展示,每次变动都发消息到服务端,根据服务端的指令来修改客户端的数据。 我们的放置游戏就是这么干的, 感觉没什么问题,当然中间也处理了一些消息顺序, 以及同步问题。 基本上保证玩家的操作在1秒内有响应,就不会影响游戏体验了。

如果游戏使用了物理引擎 要怎么做状态回退?

不太明白为什么要加时间戳,根据网络消息的先来后到不可以吗?

可以看看这篇:http://gulu-dev.com/post/2016-07-24-id-network-model-evolution 和这篇:http://gulu-dev.com/post/2016-08-11-doom3-network-architecture,感觉三代 DOOM 迭代到最后的网络模型依然是很先进的。当然了,放置类型的游戏由于游戏特点更多采用的是短连接而非长连接,而且针对文章后面的问题来说,DOOM 3 的网络模型则很具有参考价值了,毕竟 FPS 和 MOBA/RTS 一样是对同步要求极为苛刻的

之前写过一篇文章,思路差不多。

基于确定性状态机的小战场同步方案
http://blog.kezhuw.name/2016/02/25/deterministic-state-machine-based-moba-battleground-synchronization/

游戏的场景同步和互联网的数据同步、复制很相似。

erasure coding

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

@倪宇轩

需要一致的情况下,我们全部都是用顶点数计算的,在我们过去的项目里都是这样实践。

令人想起了拜占庭將軍問題.....

是的, wow里卡的时候人物动作会忽快忽慢, 估计就是这么做的.

客户端一致的计算方式有个坑。
不同CPU平台下浮点数的精度可能会不一致。实际上,不说不同的客户端平台,连客户端与服务端都会越来越不同步。

Post a comment

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