《守望先锋》中网络脚本化的武器和技能系统

Gad 2017-05-24
在GDC2017【Networking Scripted Weapons and Abilities in Overwatch】的分享会上,来自暴雪的Dan Reed介绍了《守望先锋》中网络化的脚本和工具相关技术。一起来看看吧。



嗨,大家好,我叫 Dan Reed, 是暴雪娱乐的游戏工程师(gameplay engineer,译注:游戏机制工程师,或者游戏工程师,都可以),今天主要跟大家分享《守望先锋》(后面统一用Overwatch表示)中的网络脚本化的武器和技能系统。

那么这里先简单介绍下我在Overwatch中的主要工作。


包括:

Statescript脚本系统,这也是今天我们要讲到的;

抛射物和单局游戏模式;

同时我也参与了一些特殊武器、技能和运动系统的设计;

观战模式;

一些UI也是我做的;

再有就是一些我自己都不记得的工作了。

概览(译注:这种黑体顶头格式用于每一页幻灯片上方,提领下文)


Overwatch实现了一套自己的脚本系统来编写包括武器和技能在内的高层逻辑, 这套暴雪自有(proprietary)的脚本系统叫做Statescript。

今天分享的内容包括关于Statescript的“为什么” 、“是什么”以及“如何做到的”。为什么我们决定实现这么特殊的一套系统,Statescript到底是什么?以及它背后的技术细节,这部分大约会耗时15分钟。

另外会讨论网络通信需求及解决方案,包括脚本系统在Internet环境下遇到的那些限制,以及我们是怎么应对的,约30分钟。

然后会分析一下这种方法的好处和挑战,这部分大约5分钟。

最后声明一下本次分享不会包括的内容: 抛射物、命中检测以及一些特殊技能的实现。不是说这些不重要,这些都是很棒的特性值得作为独立的议题来讨论,只是超出了今天的分享范围 。

为什么要开发Statescript


我们需要给“非程序员”提供开发上层逻辑的能力,因为我们知道需要创建大量的游戏逻辑,又不希望每个需求都要靠程序员手动编写解决方案。

我们希望这个解决方案允许用户“定义”新的游戏状态,而不仅仅是“响应”这些状态。一般典型的游戏脚本系统都有一个相当不透明的游戏模拟过程,其中脚本也能编写逻辑以响应事先定义好的事件,通过用户自己定义变量、函数调用来微调,执行的结果最后都会消失回到黑盒状态。而我们更需要的是一个形式化的、明确的方式,使得脚本开发者(译注:scripter,下面统一用开发者)对状态和状态转移能直接地、完全地掌控。

我们想要模块化的代码尽可能多地被复用,我们不会把一个特性(feature,也可译作功能)需求看作是一组垂直功能的堆叠,而是会去设计并实现那些这个特性所需的基础功能组件。

我们需要一个无痛的、稳定的方式来实现一个能够通过网络同步的状态机。手写这些代码费时费力而且容易出错,所以,最好让计算机来替你完成这些工作。

另外这个方案需要能够与项目引擎的其余部分协同工作,我们也对比了很多第三方脚本引擎,但是最终还是决定自己去开发一套能嵌入到我们的游戏引擎中的脚本语言,以得到最好的结果。

Statescript 是什么?


Statescript是一个可视化的脚本语言;每一个脚本都是一组互相连接的节点(node)形成的图(graph),代表了一段游戏逻辑的实现;这里举几个脚本的例子:猎空的“闪回”技能,卢西奥附近队友受到的加速、治疗buff,所有英雄都有的UI控件等。


当一个脚本运行时,它会创建一个运行时对象,这里称之为脚本实例(instance),每一个实例都被一个实体(entity,不懂的同学可以参考另一篇分享:Overwatch Gameplay Architecture and Netcode)所拥有,例如每个“英雄”都是一个“实体”。如果你听过Tim Ford的分享,你肯定知道这是什么。

实体上的脚本实例可以被动态地添加和删除,例如,无论何时你被麦克雷的闪光弹晕到,一段能够阻止你移动、瞄准行为的脚本实例就会动态加在你的身上,并且在一段时间内起作用,直到它被移除。

同一个实体上可以同时运行同一脚本的多个实例。

现在开始聊一下Statescript 节点(node)


在所有的节点中,首先我们有入口(Entry),Entry是脚本执行的起始点,它的作用很基础,就是在脚本开始执行时,触发一个脉冲给到它的输出(Output),当然也有好多其他类型的Entry会等待特定的消息(Message)才触发。

然后是条件(Condition),Condition会影响脚本执行流程,上图中的布尔Condition仅仅基于一些表达式的结果来输出“真”或者“假”。

接下来是动作(Action),Action基本上就是C++函数调用,这些函数在触发输出以前,会做一些立即完成的工作,像这个SetVar就是目前最常用的一个Action。

最后是状态(State),State代表一些正在进行中的工作。一个State一直是处于未激活(Inactive)状态,直到它的Begin插头(Plug,可译作接口,但是会有概念混淆)上收到脉冲信号,它才激活自己。然后State就会一直保持在这个激活状态中,直到它自己决定关闭(Deactivate),或者是因为外部原因而被动结束。

在这背后,每个State类型都是一个带有一堆虚函数的C++类(class),这些虚函数提供了一系列接口,包括OnActivate(激活)、OnDeactivate(关闭)、OnTick(轮询)、OnDependencyChange()等。这里面最重要的部分是他们都代表某种持续性的行为(behavior),而且这些行为都会在持续一段时间后停止。这个WaitState很简单,就像它的名字所描述的:“等待3秒钟就结束”。

(译注:所有的节点类型,为了避免误解,后面统一用英文单词)

变量(Variables)


Statescript提供了大量的变量,包括“实例变量”和“所有者变量”来存放数值。

每个实例都有只属于自己的一堆变量,叫实例变量。

而实例所属的实体(译注:就是实例的“所有者”),一般也含有一堆共享变量。上图中,运行在猎空的脉冲枪脚本上的子弹(Ammo)和弹夹(Clip)变量,就是这个脚本的私有变量,但“AbilityLock”变量却可以被猎空英雄实体的所有Statescript实例共享,这就是“所有者变量”。

一个变量既可以是单个的基本类型,也可以基本类型的数组。对于大部分需求来说,这已经足够了,但是至少还有一些时候,我们希望能支持嵌套结构体(nested struct)和集合(bags),我们将来会考虑实现这个功能。

变量可以是“state-defined”(状态定义)的,它们当前的值是根据当前的StatescriptState来确定的,所以基本上可以通过询问State来得到一个变量的值。

属性Properties


Statescript节点的行为是根据属性定义的;从上图右边部分中能都看到,开发者可以从事先配置好的变量(Config Vars, 译注:翻译成配置参数比较好)列表里选择需要的变量来给每个“属性”赋值;

Config Vars可以包含嵌套的属性,例如图中右上方有个“HeadPosition”配置变量里就有一个嵌套属性,你可以从另外一个Config Vars里选择,哪个实体会被赋予这个位置属性,在这里例子里就是此脚本的所有者实体。

每一个Config Vars类型都是通过C++中的一个函数来实现的,这个函数可以把这些变量的值返回给这些脚本。下图是一些Config Vars的例子:


常见Config Vars有:字面类型,变量,Utilities(基本就是一些C++函数)和表达式。表达式除了能做一些“foo是不是大于3”的无聊事情以外,还能够引用嵌套Config Vars列表,以支持更复杂的逻辑,例如:“源实体位置和目标实体位置之间的离是否大于3”。

其他Statescript功能

大部分其他功能今天没时间讲了,但是有几个我认为值得一提的还是想拿出来说一下。第一个是Subgraph(译注:子图,指的是每个节点还可以包含一个图)。


每一个State都有一个Subgraph的输出,在State激活时就会产生脉冲,而在State关闭(Deactivated)时,所有Subgraph中的State也会随之关闭。有些State会包含其他类型的Subgraph插头,会在特定的时刻激活或者关闭State。

上图中的Boolean Switch State就是个很常见的例子,它有1个TrueSubgraph和1个False Subgraph,会在条件达到时激活,条件未达到时关闭。

接下来是容器“Containers”


我们有不同的Containers变种。灰色边框的是最基本类型的Container,几乎没怎么组织,不会影响Behavior(行为);红色边框的Container定义了哪些State是Subgraph的一部分,否则的话Subgraph只会跳转一次State;蓝色边框的Container是客户端专用的;紫色的是Server端专用的。这些可以在必要的时候,在客户端和Server端生成不同功能的Behavior。

在我讲解第一个真实的脚本例子以前,我想简要的介绍一下两个重要的Statescript Theme(主题)。

第一个Theme是“生命周期保障”(lifetime guarantees)。


简单来说,也就是State的自我清理。在一个State关闭时,它的逻辑behavior执行完成,所以需要停止播放动画,清除它拥有的全部特效,重置所有改变过的变量,并关闭它激活的Subgraph,等等。

一个实例被删除时会关闭所有状态。

一个实体销毁时它会删除所有实例。

游戏结束时它会销毁所有实体。

这些都是显而易见的,但是当整个class都有bug时,开发者也不用担心,因为每个State的合约都是:如果需要清理,它自己必须实现完整的OnDeactivate接口。

另外一个Theme叫Logic Style(程序范式)。


Statescript既支持指令式(Imperative)脚本:先做这个,再检查那个,再做那个;也支持声明式(Declarative)脚本,无论何时告诉电脑做什么,它就做什么,能做到这一点的部分原因是因为我们有生命周期管理。

我们发现针对大型、复杂的需求建模,声明式脚本是最明智的选择。但是指令式也有它自己的一席之地,通常被用在声明式脚本的指令树的叶子节点上。

这就引出了我们第一个Statescript实例



“死神”本来不能用右键开火,所以现在让我们赋予他一个新的技能,流程如下:玩家按住右键1秒钟,摄像机就切入第三人称视角,代表技能现在已经开始准备,然后玩家释放按键,死神就被发射到半空中。注意,如果玩家按住右键少于1秒钟的话,什么都不会发生。

现在我们在编辑器里来搞定这个技能。


先增加一个Entry,当“死神”出生时,脚本就可以开始执行了。然后增加一个叫“LogicalButton”的State,当右键被按住时触发一个Subgraph,还有另外一个在右键没有被按住时执行的Subgraph。当右键已经被按住了1秒钟,把“ReadyToLaunch”变量设置为True。然后进入第三人称视角。


然后呆在这个状态直到右键被释放。注意:这里用来演示操作过程的视频已经被加速到2倍,实际上我是没办法弄得这么快的(众笑)。


一旦右键被释放,我们立即就会去检查ReadyToLaunch是否为True,如果按住右键足够长时间的话,那它就一定是True。而且如果我们真的这么做了,就一定能把自己发射到空中。

在那之前我先把ReadyToLaunch设置为False。

正如你所见到的,这个脚本例子混合了一个声明式风格:这个行为当且仅当按键被按下时才激活,和一个指令式风格:等待一秒钟,把变量ReadyToLaunch设为True,然后进入第三人称视角。

然后来测试这个新的技能。


一切正如我们所期望的那样:右键按下,1秒钟以后,ReadyToLaunch变成True,然后进入第三人称视角,右键抬起,我被升到空中,同时ReadyToLaunch变成False。如果我只是轻轻点一下按键,则什么都没有发生。我至少要按住右键1秒钟才能进入准备发射状态,并进入第三人称视角。

下面来做一个更加复杂的脚本。这是“猎空”的脉冲枪,嗯,这里我没时间讲解所有关于它如何运作的细节,但是你也能看出同样的原则在起作用,声明式脚本:这个为True的时候,这些事一定会发生;以及指令式脚本:先做这个事情,接着等待1秒钟然后做其他事。


在我们进入到网络部分以前,再花5分钟的时间来快速地过一遍整个Statescript系统是如何用C++实现的。


核心运行时(Core Runtime)


整个Overwatch的计时器都是基于整数的Command Frames(命令帧,也可译作指令帧,代表服务器下发到客户端的数据单位)的,所以Statescript也利用了这个特性。

每一帧是16毫秒,一秒钟刚好60帧;

每个实体都需要挂载一个Statescript组件才能执行脚本。假如你错过了之前那个很重要的分享(Overwatch Gameplay Architecture and Netcode), 那我告诉你,实体,以及Overwatch是建造在一系列组件之上的,这些组件允许系统可以执行特定的操作,这一切就是“实体组件系统模型”,简称ESC。


Statescript组件包含了所有在一个实体上执行脚本所必需的数据,会简单浏览一遍。

客户端上会有内部命令帧(Internal Command Frame),这个内部命令帧与当前正在模拟的来自Server的命令帧有所区别,后面会详细讲到。

我们有一个Statescript实例数组和一堆所有者变量,还有同步管理器(sync manager),后面会深入讲。

每一个Statescript实例都是在脚本开始、停止时动态分配的;都有唯一的实例ID用来做网络序列化;它还有一个指向Stu(译注:结构化数据的缩写,后面还会提到) Graph Asset资源的指针,Stu Graph对象里都是静态数据,不会在运行时改变;还有一个Statescript State数组,State是多态的,在脚本中首次用到时,通过一个工厂方法创建,然后就一直存在直到脚本被销毁。

这里有一个未来事件Event的列表,这些都是准备好在将来的某个时刻在某个State或者是实例自己身上执行的。事件经常在与自己入队列时相同的命令帧上被触发,有时候会带有权重在未来触发。

另外每个实例上都会有一堆实例变量。

顺便说一句,这只是数据的粗略描述,真正深入到一个运行时的Statescript里, 会看到更多标志(flags)、缓存对象列表(cached list)来优化性能。我上面列出的仅是一些最重要的数据,而且与我后面讲到的内容会有关联。

States(状态)


Statescript的State基类提供了一些实用函数,例如“访问属性数据”、“事件调度”和“注册轮询回调(registering forticking)”。

这个基类还提供了一些虚拟函数留给派生类去实现,所以我们就有了OnActivate,OnDeactivate,OnTimerEvent,OnFrameTick这些接口,如果State注册了轮询回调,那这些接口会在每个命令帧被调用到。

GetStateDefinedValue这个函数允许State给一个特定的变量提供一个on-demand值;

OnInternalDependencyChanged,接下来会马上讲到;

最后三个虚函数是用来隐藏网络抖动的,稍后也会讲到。

所有的State都有一个StatescriptDependencyListener


它是一个指向StatescriptDependencyProvider的指针数组,反过来,每一个Provider也都有一个指回Listener的指针数组,这就形成了一个多对多的关系。

Providers可以依赖于Statescript内部的变量,也可以依赖于那些Statescript以外的,被State依赖的对象。

运行的时候,Listener是在某些需要特定Providers的属性第一次被计算的时候懒加载的。所以,如果一个属性请求查询某些实体的Health(血量),State的Listener就会获得一个指向那个实体的Health组件的Provider的指针,显然,这个Provider也会同时指回Listener。

Provider变化时,会在所有Listening(译注:Listening的意思是与Provider互相指向)的State上调用OnInternalDependencyChanged。这是一个很重要的优化点,因为它意味着State不需要进行轮询(Pull)检查值是否变化,而是会收到通知。

变量Variables


StatescriptVarBags包含了一个指向StatescriptVars指针的字典表,这些StatescriptVars是在第一次使用到时才分配的。

字典每个成员的key都是一个16位ID,映射到我们的Asset库中某个已注册的asset(译注:这里需要了解暴雪的Asset管理系统)。

StatescriptVar可以是以下2种类型的任意1种:基本类型和基本类型的数组。每个基本类型都是一个128位(bit)长的联合体(Union),可以存下整形、动态数组、字符串指针等;

StatescriptVar本身也是DepenencyProvider,有任何变化时都可以通知State。

StatescriptVar也可以引用一个Statescript的State,可以获取到State的当前值。所以如果你想知道一个变量的值,只需要调用GetState即可获取当前引用的State上该变量的state-defined值。关于这一点,最常见的用法是ChaseVar State,这个State可以持续追踪变量的值变化。

继续其他议题以前,说两句关于结构化数据Stu


Overwatch中的很多资源(assets)都是用一种我们称之为结构化数据的格式定义的,简称Stu。 这里会有一个步骤来把这些.stu文件编译成代码,我们的编辑器editor、资源编译器complier和运行时runtime都能够理解并使用这些代码。对类(class )类型和数据成员添加属性、反射也是支持的。这些属性对于Statescript编辑器和资源编译器(后面我会讲到)都是很有用的。

现在可以讨论Wait State Data Schema了


这个例子里,不好意思,“在”这个例子里,有一个关于Wait State的结构化数据的定义,这里提醒一下,这个不是C++代码,而是Stu标记语言。Stu标记是用来生成描述这些数据对象的class的。

现在看下这个Stu class的第一个成员,它只有一个属性(property),就是m_timeout持续时间,代表这个Wait State的超时结束 时间。

它上方的Constraint标签告诉编辑器,把这个属性的下拉选择内容限制为那些能够提供数值型结果的ConfigVar,可以是整形或者浮点型。

在底部我们还添加了2个插头(plug),一个是用来在State被提早撤销时触发,另外一个是在等待结束时触发。

下面是Wait State的C++运行时


你可以看出它是继承自StatescriptState基类的。

顶部的宏定义DECLARE_STATESCRIPT_RTTI用来设置一些运行时类型信息(RTTI)。这个类的大部分代码都是关于重载函数OnActivate的。

首先我们定义了一个指向Stu对象的指针,Stu对象包含了这个State所需的数据,这些数据需要在编辑器里填充。

Stu对象的类是在上一页幻灯片中定义的。

然后我们调用了函数GetFloat,并把timeout ConfigVar作为参数传递给它。这样就能得到用来传递给EnqueueFinishStateEvent函数的“秒”数。经过这个时间以后,State就会触发它的Finish插头(m_onFinishPlug),然后进入关闭状态。

接下来又是2个宏定义,用来保证Abort和Finish这两个插头能够在期望的时间内触发。

最后一行还是宏定义,是用来把运行时类型和Stu结构化数据类型关联起来,这样的话,Statescript系统在代码执行到这个阶段时,就知道用哪个class来初始化。

显然我没有任何一个例子可以用来说明Actions,Conditions和ConfigVars是如何实现的,但是你们可以稍微把他们想象成State的更简化版本,他们每一个都有且仅有一个被调用的函数,而且他们的运行时版本不包含任何数据,在脚本执行时也不需要实例化任何东西,所以更简单。

以上就是关于Statescript的简单介绍了。

现在是时候来说明如何用Statescript来做一个网络游戏了


我们的第一个需求是“可用性”


它不能干扰使用者,并且抽象了全部的网络通信细节 。最早的时候。我们不想区分服务器和客户端脚本,这种恐惧来自于,即使听起来很简单的Behavious行为,实现起来也需要大量额外的脚本来同步数据,写这样的代码很乏味也容易出错。我们的游戏开发团队对于那些本应由计算机完成的工作容忍度是很低的,所以很自然地也把这个原则应用到了Statescript网络版中。


结果就是我们可以在服务器和客户端运行同样的脚本。我们发现其实也给开发者提供在必要时分离的脚本行为,但是这样做的机会不多。

响应性


必须能够适应快速响应的游戏。这意味着无论延迟有多高,玩家的操作必须能够立即有响应。这一点无需多言,否则的话,假设你开了一枪、用了一下技能或者开始冲刺,然后等待服务器回包才能收到视觉上的反馈,你一定会觉得这游戏逊毙了。

安全性


安全性是必须的,我们必须防止玩家通过发送恶意数据包来影响其他玩家的行为,没有人喜欢作弊者。

效率


它必须足够高效,允许游戏在弱网络环境中正常进行。因为Overwatch需要运行在全世界的网络上,这就意味着有时必须面对“高延迟”、“丢包”等网络问题。

无缝


它必须是无缝的,能够最小化那些可察觉的、来自网络的影响。最开始我们只是想着在遇到问题时能够有办法处理就好了,但是当我们实现了越来越多的新节点类型(node types,就是上文中提到的state,action,condition等等)以后,清晰地感觉到,我们需要一个更加正规的方法,来处理那些因为使用特定武器和技能时遇到的肉眼可见的,丑陋的拉扯、卡顿问题。

网络同步解决方案


那现在来讲一下我们是如何满足这些需求的。首先让我们来澄清一下,对于一个特定的Statescript实例,“网络同步”意味着什么。


经过同步化以后,服务器和客户端可以在使用逻辑上相同的实例。就是说,因为无需关注网络细节,大家可以公平地讨论服务器和客户端都在模拟(simulate,译注:后面会多次提到,这里采用的翻译是模拟,用在本文里有运行、执行游戏逻辑代码的含义)的同一个逻辑实例。

同步的结果是最终一致的,所以无论客户端做过什么样的预表现(Prediction,译注:翻译成预测、预演、预表现都可以),无论发生什么样的网络异常,服务器和客户端都能修正并最终回到彼此一致的状态。

另外还有非同步的实例,这些实例依然可以收到来自同步化实例的消息,也可以从同步化实例读取变量,但除此以外,他们的内部逻辑又是完全独立的。

下面是一些同步化、非同步化脚本的例子


对于同步化的实例,我们有武器、技能、表情、单局游戏模式和地图实体(大门、血包等) 。

对于非同步化的实例,我们有菜单、英雄收藏品、单局结束流程和音乐。

再说一次,正因为脚本中可以在实例之间发送消息,甚至是同步化实例和非同步化实例之间,所以我们可以做到让单局游戏模式实例控制音乐实例来播放不同的音乐。

在我们更加深入网络部分以前,关于实例,还有最后一个定义


任何一个给定的客户端上, 任何一个网络化的、可以被玩家直接控制的实体,例如:你可能正在玩猎空或者源氏,我们把这个实体和它身上的Statescript实例叫做该客户端上的local,所有其他的网络化实体都叫该客户端上的remote。

注意local实体并不是必须的,例如当播放死亡回放时,或者当前游戏内玩家没有任何可以操作的对象时,这时并没有local实体,你仅仅是在观看已经发生的一切。

服务器会跟踪记录哪些实体对于哪些客户端是local的。

现在开始讨论一下服务器权威


网络版Statescript 就是服务器权威的,这意味着服务器对于所有发生的事情,具有最终裁决权。通信通常是从服务器到客户端单向进行的,唯一的从客户端到服务器的通信就是按键输入和瞄准。

接着简单说一下从客户端到服务器的输入操作


如果你听过Tim的分享,你肯定已经看过这个流程图了,而且是更加细节的。

注意:这里的水平轴是现实世界的时间。首先,服务器下发一次更新,这是它处理过的最新的一个命令帧,在这个例子里,帧号是100。客户端收到以后,发现为了让自己可以对服务器正在发生的事情有影响,它的输入必须及时到达服务器以被正确处理。这就意味着它不能仅仅把输入操作作为100帧的回包发给服务器,因为服务器上的时间会一直流逝。所以它需要把输入作为未来的某个时刻发给服务器。但是应该有多“超前”呢?

服务器和客户端形成了一个反馈环,服务器会分析命令帧到达时有多提前或者延后,然后通知客户端这些计算后的往返时延,简称

RTT(round-triptime),所以这个例子里,假如客户端想要发送针对100帧加上RTT的时延的回包,那就是105帧,因而也就能及时到达服务器并处理。

在实践中,我们实际上是在网络条件的基础上,再超前一点点。例如,如果你的RTT频繁变化,我们的补偿就会再超前一点点来确保输入及时到达服务器。

本来我们应该再回头讲讲客户端的,但是现在我们已经知道服务器如何从客户端获取输入,那么我们可以更深入了解服务器的同步响应性。

简要概览


首先服务器从客户端收集当前命令帧的所有实体的操作,然后我们在所有的实体上执行这个命令帧,并把所有发生的变化储存在StatescriptDeltas中,最后把这些Delta(直译为“变化”,这里不做翻译了直接用Delta表示)发给所有的客户端。

我们讲讲StatescriptDeltas


如果你还能记起早前讲过的,Statescript组件都包含一个Sync Manager,用来在服务器和客户端之间对实体保持同步。在服务器端,Sync Manager持续追踪一个StatescriptDeltas的数组,这些Delta代表了实体在一个特定命令帧上经历的变化。注意,我们只在那些有变化的帧上创建Delta对象,最后来看,这部分比例很小,因为大部分时候对于一个实体来说很少发生变化。

现在过一遍StatescriptDeltas的数据结构,首先我们有命令帧,注意我们的Delta代表是一个实体在命令帧开始和结束之间的那些变化;我们还有一个包含所有发生变化且已经同步了的实例的数组,对于这个数组的每一个成员,都有这些属性:Instance ID;创建/销毁标志;以及所有发生变化的实例变量(Variable)数组,对于每个实例变量都有一个ID字段,对于数组类型实例变量,我们有一个字段代表“发生变化的数组下标范围”,通过追踪记录这个范围,我们可以避免传输整个数组;还有一个数组记录了所有发生变化的State的索引;再有一个数组记录了所有执行过的Action的索引;最后还是一个数组,记录了在一个给定命令帧上,发生过变化的所有者变量(Owner Variables)。

每一个StatescriptDeltas在所有客户端都确认收到对应的命令帧前会一直保存在服务器,确认后就没必要在保存了,可以很安全地删除它。


现在我们已经知道发生了哪些变化,但是到底应该把哪些变化发送给谁呢?这就是StatescriptGhosts的用处所在了。


StatescriptGhosts跟踪记录每个客户端对于服务器上的每一个实体的信息了解程度。现在看一下它的数据结构:客户端编号;最后一次确认的命令帧编号,证实客户端确实拥有了现在这个及之前命令帧的全部信息;一个指针数组,指向外部的StatescriptPackets数据包,这里的“外部”的意思是,我们已经发送了数据包但是还没有得到对方是否收到的答复。注意,当一个数据包被客户端确认接收(简称Ack),或者超时未接收表示发生丢包(简称Nack),Overwatch的网络底层会分别通知每一个系统模块,也包括Statescript系统。我们利用这个特性来维护StatescriptGhost对象:一旦我们得到某个数据包的Ack或者Nack,我们就把它从外部数据包列表中移除。

客户端断开连接以后,StatescriptGhosts才会销毁。

现在学习一下StatescriptPacket


还是先看数据结构:一个Local/Remote的标志,根据牵涉到的实体相对于接受者是否为Local,包数据格式会有所不同;命令帧范围起始和结束编号;最重要的payload(直译为有效载荷,指协议外的有效数据)字段,代表要传输的实际内容,为了生成这个payload,我们创建了一个命令帧范围内全部StatescriptDeltas的并集,这里的并集就是数学上的概念,基本上我们需要知道命令帧范围内的全部变化。然后我们对这个并集中引用到的所有对象的值进行序列化。

如果命令帧范围是从0开始,那它肯定是一个刚刚建立连接的客户端,那就仅仅需要发送全部对象的“当前值”即可,我们把这叫做全量更新(full update),这种情况下完全不需要关心Delta。

数据包在发送后会暂存。另外在命令帧范围相同,Local/Remote标志也相同的情况下,数据包可以重复利用。这是一个优化点:不需要花时间重新创建完全相同的payload了。

与StatescriptDeltas的工作方式类似,一个数据包也是会一直保存,直到所有客户端都已经确认收到其中的“结束帧”。


服务器同步总结


StatescriptDeltas跟踪记录实体的最近的变化情况;StatescriptGhosts跟踪记录哪个客户端对于哪个StatescriptDeltas了解多少;StatescriptPackets是可重用的有效数据payload,绑定到客户端,对应于一个或者多个StatescriptDeltas。

下面是个Demo,用来演示某个具体实体的网络同步流程


在顶部的时间线(timeline)上,能看见2个不同的Delta,对应于期间 Statescript实体发生过变化的命令帧。第一个Delta发生在100帧,我们立即创建了一个数据包并下发到客户端。经过一段时间后,在103帧上,这个实体产生了另外一次Delta。由于之前的数据包还在传输过程中,没必要重传,所以我们创建了只包含103帧Delta的数据包并下发。

等到第106帧的时候,服务器发现出问题了:它可能不会收到100帧的数据包的确认消息了。这种情况下服务器就要做决定了:重发哪些包呢?它至少必须重发100的包,但是是否重发103,现在决定还为时过早。

在这个案例中,我们最终决定多走一步,还是发送100和103两个帧包的并集,避免因为103帧也发生丢包而引发的问题。但这就意味着客户端可能收到两次103包,如你所见确实发生了。如果说冗余可以帮助一个客户端更快地从一连串的丢包中恢复过来的话,那它就完全是值得的。

客户端也懂得这种重复是服务器的策略之一,所以它不会处理第一个103包, 因为这样做不但会导致错误的执行状态(illegal simulationstate,只有103的变化,缺失了100的变化,这种状态在服务器上根本不存在),而且也没必要(后面无论如何都还会收到一次包含103包的合集,已经是最新的了,根本不需要第一个103包 )。

最后回到服务器端,收到了来自客户端关于2个数据包(译者注:一个是103的,一个是100和103合集的)的确认收到信息。事实上收到第一个确认包并不会对服务器有任何帮助,因为仅仅能够知道100包还在路上;第二个确认包则会让服务器很开心了,因为它知道100和103都确实被客户端收到了,一切都很顺利。

现在换回到客户端


客户端当前Local实体在模拟(运行)时会缓存按键输入和预表现。正如你还能记得起来的那样,Local实体是运行在一个相对于下行包更加未来的时间线上的,我们发给服务器的上行包会在服务器处理该命令帧之前到达。Local实体跑在未来,所以它用预表现来保存未确认的操作。

当收到一个来自服务器的StatescriptPacket时,首先发送一个确认收到的Ack信息,如果是超时冗余或者乱序的包,就整个忽略掉。正如之前的幻灯片中展示的例子那样。

如果StatescriptPacket是Remote,就直接复制包数据就行了。

否则,如果是Local,首先回滚所有已经执行的预表现,复制数据,然后使用之前缓存的输入,重新模拟执行到当前时刻,我们有时候管这个过程叫前滚(Roll forth)。这里要注意,执行前滚时,尽管我们使用了之前缓存的输入和瞄准操作,但新的预表现又需要被加进来。 另外,整个回滚、前滚过程都是实时发生在同一帧,玩家是发现不了的 。

我们确实需要给Statescript  State和Action添加一些实用函数来保持这个过程是无缝的,我等下就会再详细讲讲这个。

收到一个Remote包的处理过程


从上图中可以看到,客户端有一个Remote实体,从服务器收到几个StatescriptPackets以后,接受这些更新(Update),就这么简单。

注意,在大多数情况下,Remote Statescript实例既不触发节点(Node)间的link,也不处理事件,他们仅仅是轮询(Tick)那些需要刷新的State。在这个例子里,他们都是“哑”的,依赖服务器告诉他们所有的事情。这里唯一的例外是Client专有的Subgraph,只要拥有这个Subgraph的State认为它是激活的,它就会一直全量地模拟执行。

收到一个预表现包的处理过程


上图显示了客户端的一个Local实体,进行一次预表现,并收到了一个StatescriptPacket回包,图中的灰色条代表一个按键被按住不放,灰色虚线是客户端把这个输入发回给服务器,哦对不起,是发给服务器。

可以看见客户端在100帧做了一些预表现行为,来响应玩家按键。服务器上也是在同一帧执行同样的过程,然后下发一个StatescriptPackets。类似的事情也发生在103帧。

等到105帧的时候,客户端收到一个描述活动的100帧回包,所以它回滚所有在103和100帧做过的预表现,图中用洋红色表示的,直接丢弃它们。然后复制服务器版本的100帧数据,图中是用青色表示的。然后重新执行从101到105帧的全部过程(虽然作者没说明,但明显是绿色表示的),这个过程中重新构造了103帧。

注意这里的ICS代表内部命令帧(Internal Command Frame),这是Statescript系统当前正在模拟的帧。

最后当客户端收到来自服务器的第二个活动时,我们会在108帧得到一些类似的过程。

收到预测错误的包如何处理


在这个例子里, 客户端发生了一些没做预表现的事情,所以也无法进行回滚操作。引起这些的原因可能是外部的,例如被“眩晕”或者被“击杀”;假如在103帧客户端做了预表现,执行了一些操作但是服务器上并没有做,有可能是因为另外一个外部原因阻止服务器这样做了。一旦客户端意识到它在103帧上做的预表现永远收不到确认回包了它就会回滚,然后从104帧开始重新模拟到现在。

现在回头看看这些同步是如何作用于咱们刚刚给死神新增加的右键技能上的。


(译注:下面很长一段时间都是动态演示过程,最好结合视频,仅仅靠幻灯片是比较难以理解的)

现在按住右键,等待,切换到第三人称,释放,跳到空中。现在请把注意力放到屏幕右边的垂直方向的条上,这是Statescript调试器的时间线。我现在暂时停止收集数据,并回滚时间到过去来看看发生了什么事情。

屏幕左上角,你可以看见View:Server字样,说明现在显示的内容是服务器上发生过的事情,接下来我们开始对整个命令帧单步调试,当我放开右键的时候,可以看到下面的Subgraph关闭了,包括Camera 3P这个State也是,然后就能看见bool condition的ReadyToLaunch变成True了。然后我们执行这个MovementMod Action,就会把我发射到空中。最后ReadyToLaunch会被设置为False。

现在来看一下客户端都发生了什么。


还是屏幕左上角,切换View到Client。我们还是单步跟踪发射技能的模拟预表现,可以看见时间线是绿色的。如果你观察时间线上光标旁边,可以看到CF字样,CF代表命令帧(Command Frame)。这就是死神这个实体当前正在进行模拟的一帧,ICF代表内部命令帧(Internal CommandFrame),这是Statescript系统正在进行模拟的一帧。那么现在,因为我们已经执行一次预表现,这两个值(CF和ICF)是相同的,但是当我们前进几帧以后再看看会发生什么?光标进入洋红色区域,这就意味着我们从服务器收到了一个StatescriptPacket而且正在执行回滚。你会注意到现在ICF刚好在我们第一次做预表现的那一帧上。回滚完成以后,我们实际上已经处于更早的命令帧的开始阶段上了。

接下来我们会进入青色区域,复制操作开始了。注意,复制不需要跟随links。为了节省带宽,尽量做到最小化:设置变量然后更新State。

如果你很好奇为什么这些Action没有被复制,那是因为如果执行复制的话,SetVar和MovementMod这两个Action会冗余。前者是因为其中的变量已经被复制过了;后者是因为它会执行自己的复制操作。关于这些优化我会再多讲一些。

在现在的情形下,我们需要模拟回到当前,这就需要执行“前滚”。但是因为什么都没做,调试器什么也没记录,这就是为什么看起来它好像不见了,但是我们肯定会确保回到现在的。现在可以看到命令帧和内部命令帧完全相同。

那么,难道回滚和前滚不会使得程序员开发新节点(Node)类型变得更困难吗?毕竟谁也不想仅仅就是因为从服务器收到了一个包,就得重新开始播放动画或者重复播放一段声音,或者生成额外的粒子特效!


答案是:是的,它的确使得开发变难了。尽管Statescript很大程度上把开发者从网络细节下保护起来了,C++程序员还是偶尔不得不处理这种问题。为了帮助改善,State提供了很多实用函数,例如每个State的激活和关闭都有一个Reason参数,Reason可以是“服务器回滚复制”、“实体被销毁”等。State还提供了一些函数来帮助了解模拟过程当前处于哪个阶段,例如:“访问某一帧的某个State的所有活动和关闭信息” 。

然后我们还有OnBecomeActiveThisTick和OnBecomeInactiveThisTick,在一帧的最后,如果你的State的激活状态与这一帧开始时不同,这两个函数就会被客户端的Sync Manager在你的State上调用。当你的State仅仅处理输出(例如特效或者声音或者UI)而且不需要自己反馈结果给到Statescript去模拟时,这就会很有用。这种情况下,完全不用担心OnActivate和OnDeactivate的实现, 只要等到一帧的最后对这些做响应就行了,这些可以帮助在回滚和前滚场景下避免因为状态关闭开启时带来干扰(pops)和额外影响。

最后我们还有2个函数 PutUpdate和GetUpdate,用来从服务器向客户端传输State的数据,虽然很有用,但是这种函数写起来很乏味又容易出错,我们应该能够做到更好,后面会继续讲。

Action也有一些实用(Utilities)函数,可以执行单独的回滚和访问临时回滚存储。这里需要有存储是因为Action都是单例(Singleton)对象没有自己的存储区。然而我们是需要存点东西的,来避免在复制或者前滚期间播放声音。这是对于整个Action的无状态原则的一种破坏,不怎么理想,但是看起来是值得让步的。

幸运的是,我们不需要经常写这类可预测(predictable)的Action。


即使有了这些实用函数,我没还是觉得编写同步化的State有点困难。所以我们又想了另外一个办法。


我们没有用PutUpdate和GetUpdate,而是用了结构化镜像数据库自动从服务器到客户端复制数据,自动处理回滚。有了这个以后,程序员从此不再需要手动编写传输State数据的代码,实现起来更快了,bug也少了。

更好的是,程序员甚至都不需要编写定制化的逻辑来处理回滚时State的内部数据了。

现在来看另外一个例子:猎空开枪时的回滚和前滚


这里可以看到WeaponVolley这个State,在我们做本地预表现时,忽略掉了所有单次的射击(译注:这个忽略过程一定要配合视频来理解)。

这里开始回滚,因为收到服务器回包了。青色的这些是数据
最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多
【爆款新游】【潜力佳作】分析系列
推广