MMORPG的网络消息框架
原文:https://blog.codingnow.com/2016/07/req_mmo.html
只基于req/resp模式实现MMO场景服务,而不是pub/sub(订阅)
从客户端来看,MMO 这样的游戏,客户端仅仅是一个呈现设备,它不断的接收到服务器发过来的游戏世界的状态变化,而做出表现。客户端主要靠回应包来分发业务逻辑,使用请求回应模式,无非是根据回应包里的 session 号匹配之前请求包(通常包的类型在请求包中,不必放在回应包数据里),将两者(请求/回应)合并起来调用消息分发函数而已。
请求回应和订阅发放的优劣:
可以将请求回应看作是一次性的订阅发放,只是需要在上一次发放后再发起一次订阅。看起来似乎请求回应更冗余,但有很多好处。
1.它天然可以解决过载问题。如果服务器不考虑客户端的带宽/硬件处理能力而推送数据,客户端很可能无法及时处理,而无谓的浪费了双方的带宽。而请求/回应模式中,如果我还没处理完,就不会要更多,很自然的回避了问题。
2.有选择性的发送消息这件事,对服务器来说,要么是一个时间复杂度为 O(n2) 的操作:你需要对每个用户遍历对比其他用户来决定这条消息对方是否关心;要么,会把数据结构的复杂度提高一个数量级:你需要把对象进行各种分类、索引,以提高检索的效率。而如果把选择我关心的消息的权利放在客户端,那么时间复杂度就只有 O(n) 了。客户端总能更了解自己的情况。我是 iphone6s ,所以我可以关心我周围 50 个人的变化;你用个两年前的红米,屏蔽了周围所有人,服务器就不要把我旁边的人跳了个舞的事件发给我了。
3.消息优先级更好处理。我正在战斗,那些在世界聊天频道对撕的消息晚点给我,不要耽误了我的对手正在搓火球这种我更关心的事件。
具体实现:
首先把场景中的玩家、NPC 等都抽象成一样的对象。同时将场景打上合适大小网格(比如一格 100 米为边长,这取决于你允许的玩家视野),将每个格子也看成一个对象。
每个对象都带有一个(或多个)事件队列。
对于格子对象来说,事件只记录有别的对象来到这个格子,以及一个对象离开格子。
玩家对象的事件队列则记录这个对象自己做了什么动作,收到了什么伤害,增加或减少了什么 buf 等等。
你的场景应该是以某个心跳周期(通常不会超过 10Hz)来工作的,也就是游戏服务器上,所有对象的变化都是离散的。
所有的攻击、移动、技能都会以事件的方式,加上时间戳放到所属对象的事件队列中。
客户端的查询协议中的来说只有两种:
查询一个对象的当前状态以及该对象最新事件的时间戳。
查询一个对象从某个时间戳开始,以后发生的所有事件列表。
如果客户端还不了解一个对象时,它发起查询 1 ;如果已经了解了这个对象,发起查询 2 。或者根据需要发起查询 1 (例如曾经查询过,但很久都不关心了)
在查询没有回应前,限制再次提起查询的次数。
1.查询进入点。
2.根据进入点坐标,查询所在格和周围格的发起查询。
3.根据周围格的查询,获知了周围存在的对象。然后分别对这些对象做进一步查询。作为 MMO 可能存在大量的对象,通常筛选一部分就够了。
4.一旦感兴趣的对象走远,就不再监视它;这点不必依赖格子对象的状态变化。所以格子的状态反馈只需要有新增对象即可,而不必有对象离开的消息。所谓监视一个对象,指在每次查询回应后,再次提起下一次查询。有些对象,比如自己,需要响应更及时。应该同时向服务器发起多个查询请求,这样,服务器一旦判定你被攻击或被某些事件改变,总能有可用的回应 session 供发送回应包。同理,如果你在和人/ NPC 战斗,那么对手也是重点关注对象。而周围人跑来跑去、做了个动作,可能你就不那么关心了。这个可以由客户端来控制哪些该保持追踪、哪些暂时不必理会。
5.最后关注自己(在场景中那个对象)。这样如果有人攻击你,你就能知道这个事件的发生。在攻击发生后,还可以立刻去关注打你的这个人(如果之前没有关注的话)。
可以做,且容易的做的优化有:
1.事件合并。当用户请求某个时间戳之后的所有事件时,有些事件是可以合并的,比如多次移动可以合并成一个;格子里一个对象进进出出,可以合并掉,并只回应新增事件。
2.回应包缓存。把事件列表序列化为网络包有一定的开销。有些信息肯定会有很多玩家来索要,你可以只做一次序列化工作,把数据缓存起来,等多个人来请求时,直接回应给他,省去了将信息反复序列化的流程。
3.多查询合并。查询多个格子的时候,可以合并在同一个请求中,这样,对象在临近格子间的移动消息可能就被合并掉了。
原文:https://blog.codingnow.com/2017/04/mmorpg_client.html
其实、对于客户端作为状态呈现这个角色而言,请求和回应之间的关系并没有什么紧密联系,并不适合使用基于 RPC 的网络模型。这个无关乎 RPC 的实现是用 callback 还是用 coroutine 。
这是因为,当服务器的状态改变时,客户端关心的应该是这类事情发生时,我应该如何呈现;而不是具体到某个请求发出后,收到回应我应该怎么处理。RPC 把请求和回应紧密耦合在一起,让回应的处理流程强依赖请求时的上下文,这样容易引起不必要的状态管理。
比如,我看到我们公司的一个项目中曾经出过这样的 bug :UI 界面上点击一个按钮,用 callback 的形式发起了一次 RPC 调用;在回应的 callback 函数中对 UI 界面上的元素做了一系列的修改。可是在回应的网络消息收到时, UI 界面已经关闭了,由于处于节约内存的考虑,还触发了一些对象销毁流程,结果因为操控不存在的对象造成了 bug 。
你可以从别的角度来看待这个 bug 。比如说应该建立一个更稳固的 UI 框架,做更严谨的生命期管理。但我认为,根源问题就是没有把请求和回应解耦造成的。
我们应该这样看待按钮点击事件:它只是触发了一个事件在服务器上运行,这个事件导致了某个服务器上的对象状态改变了,而回应包只是通知了状态改变的结果。客户端真正要做的是怎样正确呈现这个结果。
客户端根据收到的网络包进行相应的处理,无论这个网络包是服务器的推送、还是对之前客户端发起请求的回应。在这个框架下,只关心来了一个网络包后的类别,根据这个类别来决定要做哪类事情。处理流程是关联在消息类别上的,而不是关联在单个请求上的。
通常我们会用一个 session 号来关联回应包和请求包,在发起请求时,不仅把请求内容打包发给服务器,同时还把它记录在本地,用 session 关联起来;这样在收到请求时,可以根据 session 找回当初请求的参数,以及请求的类型,这样就可以不必让服务器推送完整的状态值,客户端可以自己找到匹配的内容。
在大部分情况下,我们还需要在发起请求时,给这个 session 绑定一个本地的对象。虽然请求本身肯定有这个信息(否则服务器就不知道该请求到底操作呢什么东西),但额外的一个本地对象使用起来更方便,可以用来携带少量本地状态信息。在回应抵达时,直接操作这个绑定对象写起来更方便。
MMORPG 的同步设计:
原文:https://blog.codingnow.com/2017/08/mmorpg_sync.html
具体 bug 是这样的:
在客户端登录后,服务器会推送个玩家背包的信息,客户端应根据这些信息初始化背包对象。
在场景中的某些掉落品会自动进入玩家的背包,客户端会根据这类信息修改背包的状态。
一般情况下,推送玩家背包信息的网络包一定会先抵达。但是由于一些有关断线自动重连的设计,导致了后一种掉落品信息包抢先在了初始化过程之前。
固然,这个 bug 有简单的解决方案。比如在 UI 系统中 model 和 view 完全分离(我们现在的确是这样做的),而 model 应该先由默认值初始化,不必等待登录完成再触发背包的初始化流程。
但我认为,应该在一个更高的抽象层面来看待这个问题。
物品掉落自动进入背包这条消息是服务器推送过来的,但是这条推送到底意味着什么?对于网络游戏来说,玩家需要了解虚拟世界中发生的事件,也需要了解和他相关的对象的当前状态,这其实是两个不同的需求。
固然,如果依靠一些预先设计好的规则,客户端和服务器可以达成共识,根据虚拟世界中发生的事件,以及对象的初始状态,就可以同步对象的当前状态。很多网络游戏都基于这个方法设计,但我们应该认清本质:事件传达和状态同步是两件事,之所以选择这样的做法,其实是一种优化手段。因为我们只需要传达事件,省去了状态同步的网络流量。这个方法能成立,基于的是事先部署好的规则共识,以及事件的完整性。
MMORPG 类型的游戏的不同点在于,MMO 的世界很大,几乎不可能做全状态同步,也不可能传达世界中的所有事件。每个客户端都只关心整个世界的极小部分。所以 MMORPG 的设计中,几乎不会采用完全的初始状态 + 所有事件 的方法来同步世界状态。
也就是一部分事件仅仅只用来传达字面的意义,比如“ A 用 X 技能攻击了 B 造成了 Y 点伤害” 这种消息,可能仅用来做视觉表现以及文字 log 在屏幕上传达给玩家,而不会真的和服务器一样执行完全一样的逻辑(在 MOBA 类游戏中,参与人数较少,则可能采用完全逻辑去计算)。比如这个 X 技能导致 B 不能移动,很可能是单独的消息通知的,这样可以避免服务器和客户端由于了解的信息不对等而产生差异。
很多设计者对 “事件通知” 和 “状态同步” 这两个问题认识模糊,导致设计上含混不清。有的地方利用 “事件通知” 去计算状态变化,有的地方又单独设计状态同步协议,是很多 bug 的根源。
我的观点是:应当把 事件通知 和 状态同步 在设计层次严格分离。所有事件通知的网络包都是可以丢弃和乱序的,它仅作为客户端的视觉呈现使用;而状态同步则是应该根据客户端的实际需求来严格同步,客户端处于什么状况,需要关心哪些对象,多少对象,根据这些需要来同步 MMO 世界中的一个子集。这些需要同步的对象既包括玩家自身的数值、背包,也包括了玩家所处的场景,他附近的其他玩家和 NPC ,还可以包括聊天频道信息、任务、排行榜、拍卖行信息。
例如,玩家受伤(HP 减少),自动拾取物件,这些都是事件,如果客户端收到这些事件,可以做出对应的动画表现;而当前玩家的 HP ,背包里有些什么物品则属于状态同步管理的范畴。我们应该使用合适的同步策略来做。可以是客户端推断出状态会发生变化去查询一次和上个已知版本的差异,也可以是向服务器订阅对应的对象的状态差异变化,还可以是简单的请求对象的全量状态数据。采取何种同步方案就属于设计和优化细节了。
(自己的理解:例如获得一件新装备,如果背包没有打开的话,其实不必同步本地的背包信息,可以在打开背包的时候再提出完整的状态请求,或者根据需要在收到消息时决定是不是要查询 一次状态)
如果因为网络流量问题,我们需要做出限制的话,事件通知是可以按信息优先级丢弃或推迟送达的。怎样丢弃可以由设计人员判断信息丢失后会给玩家客户端的视觉表现造成怎样的影响。即使全部丢弃了,客户端也不应该出错,并保持基本可玩。
状态同步应该在一个更高的抽象层面设计实现。所谓同步,无非是将服务器上的一个数据结构复制到客户端。最粗笨的方法是客户端提起请求,服务器全量返回序列化后的数据结构。也可以基于前文的请求回应模式,或者服务器推送。基于客户端请求和服务器直接推送的区别仅在于:基于客户端请求需要消耗更多的客户端上行流量、而服务器直接推送则需要消耗服务器额外的开销去维护客户端到底需要同步哪些信息。至于同步延迟的问题,两者没有什么区别。因为即使是基于客户端请求,也可以提前发起请求,只在状态变化的时候服务器再回应;而不必等到客户端需要时再请求状态差异。
网友评论