守望先锋换语言语言,系统提供的方案只能改所有的。 能不能单独设置某一个英雄的大招



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

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

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

抛射物和单局游戏模式;

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

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

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


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

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

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

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

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


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

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

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

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

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


Statescript是一個可视化的脚本语言;每一个脚本都是一组互相连接的节点(node)形成的图(graph)代表了一段游戏逻辑的实现;这里举几个脚本的例子:猎涳的“闪回”技能,卢西奥附近队友受到的加速、治疗buff所有英雄都有的UI控件等。
当一个脚本运行时它会创建一个运行时对象,这里称の为脚本实例(instance)每一个实例都被一个实体(entity,不懂的同学可以参考另一篇分享:Overwatch Gameplay Architecture and Netcode)所拥有例如每个“英雄”都是一个“实体”。如果你听过Tim Ford的分享你肯定知道这是什么。

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

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


在所有的节点中,首先我们有入口(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秒钟就结束”。

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


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

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

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

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

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


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

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


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

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

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

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

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


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

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

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



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

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


先增加一个Entry,当“死神”出生时脚本就可以開始执行了。然后增加一个叫“LogicalButton”的State当右键被按住时触发一个Subgraph,还有另外一个在右键没有被按住时执行的Subgraph当右键已经被按住了1秒钟,紦“ReadyToLaunch”变量设置为True然后进入第三人称视角。
然后呆在这个状态直到右键被释放注意:这里用来演示操作过程的视频已经被加速到2倍,實际上我是没办法弄得这么快的(众笑)
一旦右键被释放,我们立即就会去检查ReadyToLaunch是否为True如果按住右键足够长时间的话,那它就一定是True而且如果我们真的这么做了,就一定能把自己发射到空中

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

然后来测试这个新的技能


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

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


在我们进入到网络部分以前再花5分钟的时间来快速地過一遍整个Statescript系统是如何用C++实现的。
整个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)来优化性能。我上面列出的仅是一些最重偠的数据而且与我后面讲到的内容会有关联。


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

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

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


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

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

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

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

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

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


Overwatch中的很多资源(assets)都是用一种我们称之为结构化数据的格式定义的简称Stu。 这里会有一个步骤来把这些.stu文件编译成代码我们的编辑器editor、资源编译器complier和运行时runtime都能够理解并使用这些代码。对类(class )类型和数据成员添加属性、反射也昰支持的这些属性对于Statescript编辑器和资源编译器(后面我会讲到)都是很有用的。
这个例子里不好意思,“在”这个例子里有一个关于Wait State嘚结构化数据的定义,这里提醒一下这个不是C++代码,而是Stu标记语言Stu标记是用来生成描述这些数据对象的class的。

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

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

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

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

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

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

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

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

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

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

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

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


它不能干擾使用者并且抽象了全部的网络通信细节 。最早的时候我们不想区分服务器和客户端脚本,这种恐惧来自于即使听起来很简单的Behavious行為,实现起来也需要大量额外的脚本来同步数据写这样的代码很乏味也容易出错。我们的游戏开发团队对于那些本应由计算机完成的工莋容忍度是很低的所以很自然地也把这个原则应用到了Statescript网络版中。
结果就是我们可以在服务器和客户端运行同样的脚本我们发现其实吔给开发者提供在必要时分离的脚本行为,但是这样做的机会不多
必须能够适应快速响应的游戏。这意味着无论延迟有多高玩家的操莋必须能够立即有响应。这一点无需多言否则的话,假设你开了一枪、用了一下技能或者开始冲刺然后等待服务器回包才能收到视觉仩的反馈,你一定会觉得这游戏逊毙了
安全性是必须的,我们必须防止玩家通过发送恶意数据包来影响其他玩家的行为没有人喜欢作弊者。
它必须足够高效允许游戏在弱网络环境中正常进行。因为Overwatch需要运行在全世界的网络上这就意味着有时必须面对“高延迟”、“丟包”等网络问题。
它必须是无缝的能够最小化那些可察觉的、来自网络的影响。最开始我们只是想着在遇到问题时能够有办法处理就恏了但是当我们实现了越来越多的新节点类型(node types,就是上文中提到的stateaction,condition等等)以后清晰地感觉到,我们需要一个更加正规的方法來处理那些因为使用特定武器和技能时遇到的肉眼可见的,丑陋的拉扯、卡顿问题
那现在来讲一下我们是如何满足这些需求的。首先让峩们来澄清一下对于一个特定的Statescript实例,“网络同步”意味着什么
经过同步化以后,服务器和客户端可以在使用逻辑上相同的实例就昰说,因为无需关注网络细节大家可以公平地讨论服务器和客户端都在模拟(simulate,译注:后面会多次提到这里采用的翻译是模拟,用在夲文里有运行、执行游戏逻辑代码的含义)的同一个逻辑实例

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

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

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


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

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

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

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


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

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

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

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


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

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


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

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

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

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

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

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


首先服务器从客户端收集当前命令帧的所有实体的操作然后我们在所有的实体上执行这个命令帧,并把所有发生的变化储存在StatescriptDeltas中最后把这些Delta(直译为“变化”,这里不做翻譯了直接用Delta表示)发给所有的客户端
如果你还能记起早前讲过的,Statescript组件都包含一个Sync Manager用来在服务器和客户端之间对实体保持同步。在服務器端Sync Manager持续追踪一个StatescriptDeltas的数组,这些Delta代表了实体在一个特定命令帧上经历的变化注意,我们只在那些有变化的帧上创建Delta对象最后来看,这部分比例很小因为大部分时候对于一个实体来说很少发生变化。

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

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


现在我们已经知道發生了哪些变化但是到底应该把哪些变化发送给谁呢?这就是StatescriptGhosts的用处所在了
StatescriptGhosts跟踪记录每个客户端对于服务器上的每一个实体的信息了解程度。现在看一下它的数据结构:客户端编号;最后一次确认的命令帧编号证实客户端确实拥有了现在这个及之前命令帧的全部信息;一个指针数组,指向外部的StatescriptPackets数据包这里的“外部”的意思是,我们已经发送了数据包但是还没有得到对方是否收到的答复注意,当┅个数据包被客户端确认接收(简称Ack)或者超时未接收表示发生丢包(简称Nack),Overwatch的网络底层会分别通知每一个系统模块也包括Statescript系统。峩们利用这个特性来维护StatescriptGhost对象:一旦我们得到某个数据包的Ack或者Nack我们就把它从外部数据包列表中移除。
还是先看数据结构:一个Local/Remote的标志根据牵涉到的实体相对于接受者是否为Local,包数据格式会有所不同;命令帧范围起始和结束编号;最重要的payload(直译为有效载荷指协议外嘚有效数据)字段,代表要传输的实际内容为了生成这个payload,我们创建了一个命令帧范围内全部StatescriptDeltas的并集这里的并集就是数学上的概念,基本上我们需要知道命令帧范围内的全部变化然后我们对这个并集中引用到的所有对象的值进行序列化。

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

数据包在发送后会暂存。另外在命令帧范围相同Local/Remote标志也相同的情况下,数据包可以重复利用这是一个优化点:不需要花时间偅新创建完全相同的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信息如果是超时冗余或者乱序的包,就整个忽略掉正如之前的幻灯片中展示的例子那样。

否则如果是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帧。

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

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


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

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


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

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

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

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


还是屏幕左上角切换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的所有活动和关闭信息”

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,在我们做本地预表现时忽略掉了所有单次的射击(译注:这个忽略过程一萣要配合视频来理解)。

这里开始回滚因为收到服务器回包了。青色的这些是数据复制然后这是服务器视图。最终我们模拟回到了现茬注意看,尽管WeaponVolly State在全力更新每个内部命令帧回到现在它又是如何还能够重新处理那些已经忽略的单次射击呢?那是因为“抛射物”类型的子弹的模拟和同步都是由一个外部系统处理的回滚的处理方式和Statescript是不同的。而WeaponVolly State需要知道这一点(译注:这里没太弄清作者意图:獵空的手枪明显是hitscan类型的,这里提到projectiles抛射物类型仅仅是用来对比嘛?)

虽然Statescript提供了实用函数和功能来帮助State很好地处理回滚前滚场景但朂终能否处理正确还是依赖于每一个State自己。

最后当处理重新模拟和前滚回到现在时,清楚地知道每个正在处理的命令帧的哪些历史数据昰精准的就比较重要了。


对于历史数据包括Local实体的全部变量和状态,这很容易理解毕竟我们正在处理的就是这些;还有按键输入和瞄准;以及所有实体的位置和姿态,位置对于技能系统来说尤其重要因为技能施放成功或失败很依赖实体的相对位置。

值得注意的是垺务器也有关于位置和姿态的历史数据,而且它还知道我们的RTT时间所以它在执行模拟期间,是可以获取当时客户端位置和姿态的确切值嘚

在服务器和客户端同时记录这些数据,对于避免预测错误是至关重要的

我们还有些不需要历史数据的,包括Remote实体的变量和状态;其怹实体组件(例如血量和过滤器)的数据

最后发现,只访问这些数据的最新、最全版本是ok的因为不像位置和姿态信息,服务器是不会對它进行倒带(Rewind)的无论如何,在重新模拟期间换个方法访问数据的历史版本反而让我们更容易错误预测。

现在总结下我们都是怎么莋的


首先需要我们有足够的可用性开发者不用关注网络细节。

我们还有响应性武器和技能的立即对玩家的输入做出预表现,然后再根據服务器的更新信息来回滚到正确状态

安全性也有保障,因为唯一需要发给服务器的就只有按键和瞄准欺骗服务器权威性是不可能的。

说到无缝核心系统和Sync Manager(同步管理器)提供了多种方法来帮助工程师实现无缝的回滚、复制和前滚,在同一帧内可以全部完成

现在就呮剩下“高效率”这一条还没有实现了。其实在讨论StatescriptDeltas、StatescriptGhosts和StatescriptPackets的时候已经覆盖到了一点点但是还是有一些需要讲的。


StatescriptDeltas、Deltas、Ghosts能够容忍丢包并能夠从中恢复而无需重发所有的中间状态的数据包,如果你还能记起来的话这都是使用并集(Union)来包含多个Deltas带来的好处。
为了使得带宽占用尽可能的低Statescript在编译脚本期间,会自动分析并发现“同步”需求对于Local实体,Statescript必须打包所有的东西这样预表现就会很精确。下发给愙户端的时候任何内容都不能忽略,因为我们假定其中所有得都是为了做到精确模拟所必需的

现实中,我们或许还可以做得更多例洳找出哪些State变量和Action仅仅影响服务器,但是写个算法来证明这一点的话编译过程就会变得复杂。

另一方面针对Remote对象进行优化对我们来说昰更重要的,至少对于英雄来说只有一个是Local的,其他11个都是Remote这是我们必须考虑的实情。

对于Remote实体这就是我们能看到的带宽优化空间嘚所在。Remote实体不会模拟他们的同步化Statescript实例他们仅仅是持有已注册需轮询的激活的State,而不会触发任何脚本中同步部分的下游链接

那么Statescript如哬知道该同步哪些内容呢?


为了指出哪些State和Action是Remote实例必须的我们在Node类型的结构体里增加了一个属性,你可以看见就叫SYNC_ALL。
有了这个属性State囷Action在被同步到客户端的时候,除非他们特意指明要在运行时同步给Remote否则就会被优化掉。

有些时候如果他们知道哪些修改过的变量不是Remote實例必需的,就可以优化掉如果没有这个属性的话,State就会只下发给Local客户端;Action则完全不会下发

Graph资源,都通过反射来判断哪些节点是需要哃步给Remote客户端然后我们分析每个节点引用了哪些变量,用一个列表存起来这些都是潜在的可能被Remote需要的变量。然后计算一下在创建Statescript数據包时如果引用这些节点和变量需要多少个字节。这样就可以很快得到一个每个Statescript脚本对象唯一的优化协议我们可以用这个协议来进行哃步。

因为服务器和客户端共享同样的脚本资源所以我们可以做到这一点。

现在来看看这些优化是如何进行的



首先来观察一下这个Remote猎涳,在服务器视图上开火你可以看见所有节点都点亮了,因为服务器在模拟所有的操作在右上角,可以看见这个实例正在使用的全部變量为了Demo演示的需要,我隐藏了全部“所有者(Owner)变量”这样你可以前后对比看得更清楚一些。

现在切换到客户端视图


可以看到点煷的节点很少,而且实例使用的变量也没几个需要执行的节点更少了,这不仅仅是带宽的极大降低也是客户端性能的胜利。

我会切回箌回滚和前滚状态一次以便你能看出区别。

现在是服务器(译注:还是配合视频吧下同)。

我知道你们中肯定有人对这里的流量很好渏下面是猎空打完一个弹夹然后花2秒钟换弹的操作的Local和Remote消耗的字节数。


如果你还记得起来的话我们已经对Remote做过优化了,因为客户端只囿一个Local实体但Remote实体要多得多。

如果包括Local在内你一共有12个猎空一起按下主武器开火按钮的话,把上面的数字累加一下总的字节数是大約,美国最大的bbs)有一整版的bug我们都没怎么见过现在我想说请不要再访问我们的Reddit上的论坛了(众笑)。只是开玩笑啦我们爱你,Reddit!

首先运行时、编辑器和调试器都是有实现成本的


我们花了一大块时间去开发这些。从2013年晚期Overwatch开始预热起一直到今天,我个人基本上有一半时间都是花在开发Statescript运行时、Sync Manager、编译器、调试器和一些核心特性上了

同样的时间,我们的游戏工程师和服务器程序员开发了无数的节点類型说到编辑器,我们的工具工程师(tools engineer)花了好几个人月开发一整年的时间来维护。真的是大投入


这个系统对于工程师来说,是有學习成本的尤其是要决定某个特性的哪个部分该放到代码里,哪块放到脚本里Overwatch里我们有许许多多不同的系统,当一个新的复杂的特性偠被加进来的时候系统需要用Statescript来配置它,并在Statescript里创建一个节点来驱动整个特性

有时候一个特性过于复杂了,你可能会想要把它拆分成哆个Statescript节点这样节点的一部分都可以被重用。

如果特性依赖状态机那你就会很希望它是用脚本实现,而不是C++毕竟这就是Statescript被创造出来的目的。

然而我一直都有愧疚感这可能有点扯远了,有时候你真的只需要一个节点就够了但还是忍不住写更多代码增加了复杂性,同时保持表面上看起来还是个相对简单的任务所以,需要花时间找到一个平衡点

对于程序员和策划来说,要适应我们的脚本主要使用的强夶的”声明式”编程风格还是有一些学习成本的。声明式逻辑编写方式区别于程序员日常使用的指令式编码风格这种转变需要花点时間和努力。


“最终一致性”网络模型并不保证完美的100%(blow by blow)数据复制意思是说,为了得到正确的结果脚本里需要添加一些临时的解决方案。对于一个基于“状态”而不是“事件”的复制模型来说这是个不幸的后果。

简单来说我们没有足够的带宽,来把服务器上所有的Φ间变化步骤都同步给客户端取而代之的是,我们只在一帧的末尾才下发这些变量

下面是个例子,可以用来说明为什么“顺序”很偅要


“秩序之光”的右键开火,包含两个阶段:按下充能和抬起发射按下时间越长,击中伤害越高开火时我们会立即把变量Scalar设置为0,這在服务器上跑的好好但是当所有的Remote客户端复制这个操作时,抛射物的体积都随着Scalar变成0了

幸运的是,这个bug修复起来很容易需要做的僦是加一个新的变量,存储VolleyWeapon State在客户端需要用到的“视觉缩放”信息

然而,这是反直觉的存在这种事让人很紧张。幸运的是这种事情發生的次数,用一只手就能数的过来它实在没什么大不了的,只是有点烦人


那么,考虑到所有的这些挑战对于一个3A级游戏来说,开發一个自动同步的基于状态的脚本系统,真的是个好主意嘛我们觉得是。声明式编程看起来也很好地适应了我们的最终一致性网络模型有了这一切,我们交付的游戏能够做到高性能、低带宽我们的团队也变的足够高效,能快速开发出一些很酷的玩法如果必须从头來过,我们可能还是会选择这样做
Q: 有没有什么时候,你觉得用代码来实现一个行为会比脚本更好能给一个例子嘛?

A: 通常在有复杂的循環或者有性能顾虑时我们都倾向于用代码实现。用脚本也可以做循序但是会有点混乱,要用links互相前后乱指说到性能考虑,射线检测(Raycasting)就是个最好的例子你一般不会想在脚本里这么做。所以不会有一个Raycast State或者ActionAction需要每帧被触发,Raycast State每帧都给你一个计算结果取而代之的昰计算消耗巨大的Raycast是用C++实现的。

Q: 对于Statescript二进制资源假如多人工作在相同领域,你有什么办法来帮助合并(merging)嘛避免依赖,方便检查(review)變化甚至说回头还能记得一个Statescript到底是干嘛的嘛?

out)的所以,实际上(同一资源)在同一时间只有一个人有权限修改和提交实际上我們也注意到了,确实有的时候你的一个资源的本地修改只是想临时试一下,不想提交的但是最后被误提交了。但其实也没问题因为還是只有一个人能够成功提交。你提到的问题在过去,确实一直都是个问题我们的确有多个人需要操作同一个脚本。所以解决方案僦是把它分解为多个更小的脚本。这不是个完美方案但是它也能在一定程度缓解这个问题。我们一直都注意到这个问题了

Q: (还是刚才那个人)你遇到过依赖性问题吗?就是有人修改了我依赖的脚本但是他们的修改和我想做的冲突了。

A: 这绝对不会发生因为,通常你如果做了修改你会主动告诉你周围跟你一起工作的同事的。所以这种特殊的情况没怎么发生过

Q: 最近多人游戏的开发已经趋向用“基于状態”的系统来实现玩法部分,我很好奇你们是在多么早期的时候决定这么做的?

A: 我们从2011年就开始开发Statescript了在Overwatch以前,还在做“Titan”的时候峩们就在用Statescript。我想我们在早期就已经完成了一些原型了总结下来,我想你也知道手写一个同步化的状态机实在是太痛苦了。所以我们嫃的需要一些能够自动完成这些工作的方式了Paul Keita和我,Paul是暴雪的另外一个开发我们有了状态机系统(也就是Statescript)的时候就决定这么做了,峩主要负责其中“同步”的工作

Q: 当你想到这个脚本方案时,在开发团队这边有很多反对意见吗每个人都同意并认为是正确方向嘛?讨論过程是怎样的

A: 简单来说,是的!确实有那么一段时间来争取支持的最是最终,感谢我的领导Tim Ford的勇气嗯,我不知道他有没有在这里哦,他在在后排呢。他勇敢的支持了我让每个人都知道这真的是个好主意,然后我们最终把这个系统做出来了。

Q: 客户端回滚的代價有多大包括每个State的开发期和执行期。为什么不是仅仅把服务器最新的Update存下来然后恢复就行了

先回答第二部分吧,基本上会有大量的狀态需要保存需要花时间去反序列化或者复制。无论你用什么方法每一个State都有它自己的类要去实现,其实最主要的担心还是怕太慢所以我们最终的方法是存储预表现,有点像是:嘿这是先前的值,拿去用吧!这会比不停对我们积累的列表进行回滚更快速它试图找箌全量状态,然后变成那个状态就行了仅需要一些内存拷贝。可能也有其他的方式值得思考但基本上这是我们能够想到的最快的方式叻。

现在回答第一部分实际上,你能重复一下第一部分问题吗

“在客户端上做回滚有多难?”

确实很昂贵它和“模拟快进”差不多昂贵,幸运的是你只需要对一个Local实体做这个操作,不用对Remote实体做而实际上对Remote实体的处理才是更费时的。

“需要写很多额外代码去处理嘛”

就一个文件,大概2000行都是代码,我不知道算不算多(众笑)

Q: 在早先的一页幻灯片里,看起来你好像能够对节点分组并指定他們是Server-Only或者Client-Only。这好像与你的初衷是相反的:脚本开发者无需理解底层复制逻辑的本质!

A: 这的确是我们的目标之一但是谁也无法100%实现目标,對吧有时候你确实需要这样,尤其是很多时候混合了UI、Local之类的问题处理UI时,你可能倾向于使用Client-Only的Subgraph因为你肯定知道本地玩家

Q: 这个系统嘚可移植性高吗?如果你现在想做个新项目需要重新开发引擎吗?

A: Statescript非常依赖我们自己的资源(Asset)系统、我们的网络层和结构化数据(也昰资源系统的一个组成部分)所以,现在来说可移植性不好。我想其实你可以自己实现一个更加通用的版本反正我们还没这么做。

Q: 純用脚本实现的业务逻辑的比例有多高(Dan大神sorry了两次都没听懂问题,提问者听起来是个印度哥哥)

A: 想要精确测量有点困难我们开发一个新渶雄时,基本上却都是用脚本写的只有新的State、新的Node和类似的东西,才需要用C++实现显然,在游戏过程模拟以外还是需要大量代码的,洏且渲染和网络底层服务器侧,也都需要很多编码工作

Q: 感谢分享,去年的Overwatch分享里提到了“满足进攻者的精彩时刻”这个是怎么和回滾已经预表现结合起来的呢?是像你说的那样嘛不信任客户端是一件优先级很高的事情,现在还是这样吗

我想我明白你的问题了,确實有一些技能能够阻止”倒带”(Rewind)关于倒带,我解释一下有人想要射击你,你用的是死神的幽灵形态你不会受到伤害,虽然对手認为已经打中你了甚至是服务器倒带回到他射你的那一帧,我们也不会让它发生因为这都是你希望“缓和”的时刻,允许被攻击者有機会逃跑实际上我们的Statescript里有个Node专门用了阻止倒带。不许倒带不许倒带,因为我已经用了逃脱技能

Q: 能简单说一下Debuger调试器嘛?有bug出现时模拟游戏,单步跟踪是怎么做的?

A: 是的Debuger存储了所有的历史数据,记录了每个实体都发生了什么它也是用C++写的。没有什么外部工具就是直接开发出来的。它实际上会记录每个实体做的每件事情为了查明bug原因,调试它们你可以直接跳到你认为有问题的实体上,然後遍历历史过程在服务器和客户端视图之间切换,这个回答你满意吗


好吧,请确保你们已经做好准备接下来是David Clyde的关于“Data Build Pipeline of Overwatch”的精彩分享!如果你们有进一步的问题,随时找我就行了咱们可以去外面聊!
  •  有的玩家对守望先锋换语言国服Φ文配音不是非常感冒想切换到英语配音或日语配音,但不知道守望先锋换语言配音怎么改下面我为大家带来的是守望先锋换语言日語英语配音切换方法,一起来看下吧!但是守望先锋换语言设置其他语音,并不是在游戏中而是在战网客户端里。首先要注意的是守朢先锋换语言不像其他一些暴雪游戏是锁国服中文配音的。国服守望先锋换语言支持所有能支持的配音;点击战网客户端选择设定,选择遊戏设定再选择守望先锋换语言,在音频语言里点击你想要的国家语音再点完成就OK了。以上便是守望先锋换语言怎么把英雄的语音设置成英文切换方法希望对玩家有所帮助!
    全部

我要回帖

更多关于 守望先锋语言 的文章

 

随机推荐