多玩家游戏与共享现实有关:所有的玩家都感觉他们都在同一世界里,在这个世界里以不同视角看着相同的事件发生。最初的多玩家游戏是双玩家的调制解调器游戏,以DOOM为代表,而现在多玩家游戏已经进化成为大型的、持久的、交互形式更加自由的游戏,如Quake2,Unreal和Ultima Online,共享现实背后的技术已经有了巨大的进步。
需要实现的一件重要事情是:如果你计划要在游戏中支持联网的多玩家,在你开发游戏的时候要建立和测试网络!建立一个高效的网络实现对于游戏对象设计决策具有重要影响。改进解决方案是很困难的,当不考虑网络时,像跨越很多对象分解功能这样的重要决策可能会在多玩家游戏中带来重大问题。
在一开始的时候,有像Doom 和 Duke Nukem这样的点对点模型,游戏中每台机器都是平等的。每台机器都精准地同步其输入,并与其他机器校准时间,而且每台机器在完全相同的输入上执行相同的精确游戏逻辑。结合了完全确定的(即固定速率,非随机)游戏逻辑,机器中的所有玩家都感知到相同的现实。
这种方法的优势是简洁。劣势是:
- 缺乏持久性。所有的玩家都必须一起开始游戏,新玩家不能随心所欲地想来就来想走就走。
- 缺乏玩家可扩展性。由于网络架构的锁步性质,协调的开销和网络引起故障的可能性随着玩家数量的增加线性增加。
- 缺乏帧率可扩展性。所有玩家必须以相同的内部帧率运行,这很难支持多种机器速度。
下一个是庞大的客户端-服务器架构,Quake最先使用,之后Ultima Online也使用了这种架构。在这种模型中,一台机器被指定为“服务器”,负责进行所有的游戏运行决策。其他机器是“客户端”,它们被看作是不声不响的显示终端,这些机器会把它们的“击键”发送给服务器,之后收到需要呈现对象的列表。这种进步支持大规模的互联网游戏,因为游戏服务器开始在互联网上兴起。客户端 -服务器体系结构随后被QuakeWorld和Quake 2扩展,将额外的模拟和预测逻辑移到客户端,以便在降低带宽使用的同时增加可见的细节。这里,客户端不仅接收要呈现的对象的列表,而且要接收关于它们的轨迹的信息,因此客户端可以对对象运动做出初步的预测。此外,这种模型引入了锁步预测协议,为的是消除客户端动作中的感知延迟。
但是,这种方式还是有一些缺点:
- 缺乏开放性——当用户和有权限的人创建新对象类型时(武器、玩家控件等),必须创建黏合逻辑以指定这些新对象模拟和预测方面的内容。
- 预测模型的困难——在这个模型中,网络代码和游戏代码各自是独立的模块,但是每个模块都必须充分了解对方的实现,为的是保持游戏状态合理同步。(理想上)不同独立模块间的强耦合是不合需要的,因为这会让扩展变得困难。
Unreal将一个新方法引入到多玩家游戏中,术语叫广义客户端-服务器模型。在这个模型中,服务器在游戏状态的演变的过程中仍然是权威。然而,客户端实际上在本地保留了游戏状态的准确子集,并且可以通过执行与服务器相同的游戏代码来预测游戏流程,对于大致相同的数据,从而最小化必须在两个机器之间交换的数据量。服务器通过复制相关参与者及其复制属性来向客户端发送有关世界的信息。客户端和服务器还通过复制函数进行通信,当一个参与者调用了函数,这些复制函数仅在拥有该参与者的服务器和客户端之间复制。
进一步讲,游戏状态是通过一种可扩展的面向对象脚本语言UnrealScript自描述的,UnrealScript将游戏逻辑从网络代码中完全解耦出来。网络代码以这样一种方式推广:它可以适应任何被语言描述的游戏。这就达到了面向对象提升可扩展性的目的,有一种概念认为对象的行为应该完全被该对象描述,不要引入对于其他代码块的依赖,这些代码块通过硬编码才能了解该对象的内部实现。
这里的目标是以一种相对严格的方式定义Unreal的网络架构,因为这复杂性很高,如果没有准确定义就会很容易产生误解。
我们精确定义基本术语:
- 变量(variable)是固定名称和可修改值之间的联系。变量的例子包括像X=123这样的整型,Y=3.14这样的浮点数,Team="Rangers"这样的字符串,和 V=(1.5,2.5,-0.5)这样的向量。
- 对象(object) 是一个自包含的数据结构,由一组固定变量组成。
- 参与者(actor)是可以在一个级别上独立移动的对象,而且可以在该级别上于其他参与者交互。
- 级别(level)是一个包含了一组参与者的对象。
- 滴答(tick)是一个操作,假如一段变量DeltaTime时间已经过去了,该操作就会更新整个游戏。
- 级别的游戏状态(game state)指的是存在于当前级别中所有参与者的完整集合,以及目前不进行滴答操作时所有变量的当前值。
- 客户端(client)是UE的一个运行实例,它保留了适合粗略模拟世界中发生事件的游戏状态的近似子集,该子集也适合为玩家呈现一个世界的大致视图。
- 服务器(server)是UE的一个运行实例,它负责为一个级别滴答, 权威地与所有客户端进行通信。
可能除了滴答和游戏状态,上述所有概念都很容易理解。那么,我们之后会更详细地进行介绍。首先,这是对于Unreal更新循环的简单描述:
- 如果我是一台服务器,我与所有客户端通信当前游戏状态。
- 如果我是一个客户端,我向服务器发送我请求的动作,从服务器收到新的游戏状态信息,向屏幕渲染我当前的近似世界视图。
- 如果在之前滴答操作之后已经过去了一段变量DeltaTime时间,进行一个滴答操作来更新游戏状态。
一个滴答操作包括更新级别中的所有参与者,完成参与者的物理操作,通知它们已经发生的有趣游戏事件,执行任何需要的脚本代码。Unreal中所有的物理现象和更新代码都设计用于处理一个变量时间的流逝。
例如,Unreal的行动物理看起来像:
位置 += 速度 *DeltaTime
这带来了更强的帧率可扩展性。
当正在进行一个滴答操作时,游戏状态被执行的代码持续更新。准确来说游戏状态可以以三种方式改变:
- 可以修改参与者中的一个变量
- 可以创建一个参与者
- 可以销毁一个参与者
从上面来看,服务器的游戏状态是由处于同一级别的所有参与者的所有变量集合完全、简明地确定的。因为服务器对于游戏运行流是权威的,服务器的游戏状态可以总是被看做一个真实的游戏状态。客户端机器上的游戏状态版本应该总是被看做近似服从各种不同误差,这些误差来自于服务器游戏状态。存在于客户端机器上的参与者可以被看做是代理,因为它们是一个对象暂时的、近似的代表,而不是对象本身。
当客户端加载一个级别在联网的多玩家游戏中使用时,它会删除该级别上的所有参与者,除了那些将bNoDelete设置为true或bStatic设置为true的参与者。其他与该客户端相关的参与者(由服务器决定)会被从服务器复制到客户端。一些参与者(如GameInfo Actor)从不会被复制到客户端。
如果网络带宽是无限的,网络代码就会非常简单:在每个滴答操作的最后,服务器只是会向客户端发送完整的、准确的游戏状态,所以客户端总是会显示正在服务器上发生的游戏界面。但是,互联网现实是:28.8K的调制解调器可能只有带宽的1%是需要通信完整、准确更新的。但是消费者的网络连接未来会更快,带宽的增长率比摩尔定律低得多,摩尔定律定义了游戏和图形学中的增长率。因此,现在和未来对于完全游戏状态更新来讲,都不会有足够的带宽。
所以,网络代码的主要目标就是让服务器能够与客户端通信游戏的合理近似状态,这样一来客户端就可以渲染世界的交互视图,这些视图很接近共享现实,如果给定了带宽限制,这也会很合理。
Unreal把“在服务器和客户端之间协调处一个合理的共享现实近似值”的问题,看成“复制”的问题。也就是说,问题是:决定数据和指令集,这些数据和指令从客户端和服务器之间流动,为的是实现这个近似共享现实。
总的来说,每个都有一个Role和RemoteRole属性,这些属性在服务器和客户端上有不同的值。服务器上的每个参与者都有一个Role设置给ROLE_Authority。
服务器上的参与者可能有它们的RemoteRole:
- ROLE_AutonomousProxy——被复制到拥有客户端时,PlayerControllers和它们控制的Pawn
- ROLE_SimulatedProxy——所有其他复制的参与者
- ROLE_None——从不会被复制给任何客户端的参与者
服务器上参与者的RemoteRole是该参与者在客户端上的Role。所有复制到客户端的参与者都有设置到ROLE_Authority的RemoteRole。.
Actor类定义了ENetRole枚举和两个变量Role和RemoteRole,如下:
Actor.uc
//Net variables.
enumENetRole
{
ROLE_None, // 没有角色
ROLE_SimulatedProxy, // 该参与者的本地模拟代理
ROLE_AutonomousProxy, //该参与者的本地自治代理
ROLE_Authority, // 对于参与者的权威控制
};
varENetRole RemoteRole, Role;
Role 和 RemoteRole变量描述了本地和远程机器分别对于参与者有多大控制能力:
- Role == ROLE_SimulatedProxy ——意味着参与者是临时的、近似的代理,这种代理可以模拟物理和动画。在客户端,模拟的代理执行它们基本的物理运动(线性的或者受重力影响的移动和碰撞),但是它们不会做任何高等级的移动决策,它们就这么进行。 它们可以用simulated关键字执行脚本函数,而且可以进入标记为simulated的状态。这种情况只可能在网络客户端中发生,不会在网络服务器或单人游戏中发生。
- Role == ROLE_AutonomousProxy——意味着参与者是本地玩家。自治代理对于客户端的移动预测(不是模拟)有特殊的逻辑。这些代理可以在客户端上执行任何脚本函数;而且它们可以进入任何状态。这种情况只在网络客户端中发生, 不会在网络服务器或单人游戏中发生。
- Role == ROLE_Authority ——意味着该机器对于参与者有绝对的、权威的控制。
这是所有单人游戏的情况。这些游戏可以执行任何脚本函数;而且可以进入任何状态。
这是服务器上所有参与者的情况。
在客户端上,这是针对客户端在本地产生参与者的情况,比如无端的特效,这些特效是在客户端完成的,为的是节约带宽用量。
在服务器端,所有的参与者都有Role ==ROLE_Authority,而且RemoteRole设置给代理类型之一。在客户端,Role和RemoteRole总是与服务器值正好反过来。不出所料,这是来自Role和RemoteRole的含义。
大多数值的含义都是在UnrealScript类中用复制语句定义的,比如Actor和PlayerPawn。下面有几个例子,关于复制语句怎样定义不同角色值的含义:
注:这些例子是与UE1和UE2严格相关的;但是核心概念与UE3是相同的:
- Actor.AmbientSound变量是由服务器发送给客户端的,因为在Actor类中的复制定义:
o if(Role== ROLE_Authority) AmbientSound;
- Actor.AnimSequence变量是由服务器发送给客户端的,但是只对渲染成网格的参与者是这样,因为在Actor类中的复制定义:
o if(DrawType== DT_Mesh && RemoteRole <= ROLE_SimulatedProxy) AnimSequence;
- 当模拟代理最初产生时,服务器会向客户端发送所有这些模拟代理和移动刷子的速率,因为在Actor类中的复制定义:
o if((RemoteRole== ROLE_SimulatedProxy && (bNetInitial || bSimulatedPawn)) || bIsMover)Velocity;
通过在所有的UnrealScript类中研究复制语句,你会理解所有角色的内部工作机制。关于复制,很少有“behind-the-scenes _magic_”发生:在低级的C++级别上,引擎提供了复制参与者、函数调用和变量的基本机制。在较高的UnrealScript级别上,不同网络角色的含义是这样定义的:基于不同的角色指定什么变量和函数应该被复制。所以,在UnrealScript中,角色的含义几乎是自定义的,例外是一小部分的behind-the-scenesC++逻辑,这些逻辑有条件地为模拟代理更新物理和动画。
定义
一个Unreal关卡可能很巨大,任何时候玩家都只能看到关卡中参与者的一小部分,关卡中大部分的其他参与者都是不可见的、不可听到的,而且对于玩家没有重大影响。服务器认为可见或者能够影响客户端的参与者都被视为是与该客户端有关的参与者集合。Unreal网络代码中的显著带宽优化是服务器只告诉客户端关于该客户端中相关集合的事情。
在为一个玩家确定相关的参与者集合时,Unreal(按顺序)应用如下规则:
- 如果参与者的RemoteRole是ROLE_None,那就是不相关的。
- 如果该参与者与附属于另外一个参与者的骨架,那么其相关性是由它的基类的相关性决定的。
- 如果参与者有bAlwaysRelevant,那就是相关的。
- 如果角色的bOnlyRelevantToOwner设置为true(用于详细目录),它可能只是与客户端相关,而且该客户端的玩家拥有该参与者。
- 如果玩家拥有参与者(Owner == Player),那就是相关的。
- 如果参与者是隐藏的(bHidden = true),那么它就不会碰撞(bBlockPlayers = false),也不会有环境音(AmbientSound == None),那么该参与者就是不相关的。
- 根据参与者位置和玩家位置之间的瞄准线校验,如果参与者是可见的,那就是相关的。
- 如果参与者在之前的2到10秒之内是可见的(确切的数字是不定的,因为一些性能优化),那就是相关的。
注意bStatic和bNoDelete参与者(存在于客户端)也可以被复制。
制定这些规则是为了给出参与者集的良好近似解,这些参与者真的可以影响玩家。当然,这是不完美的:如果有巨大的参与者,瞄准线校验有时可能会错误的负值(尽管我们会用一些启发法来解决这个问题),它不负责环境音的声音遮蔽等事情。然而,近似解就是这样,它的错误被网络环境中固有的错误所淹没,互联网会有延迟和包丢失这样的特征。
在基于调制解调器的互联网连接中,在死亡竞赛游戏中,服务器几乎永远不会有足够的带宽来告诉每个客户端它们想要了解的游戏状态,Unreal用了一个负载平衡小技巧,优先排序所有的参与者,基于每个参与者对于游戏运行的重要性,再给每个参与者一个比较公平的带宽份额。
每个参与者都有叫NetPriority的浮点型变量,数值越高,该参与者相对于其他参与者收到的带宽就越多。一个拥有2.0优先权的参与者正好是1.0优先权参与者更新频率的两倍。关于优先权唯一重要的事是它们的比率;所以很明显,你不能通过增加所有优先权来提升Unreal的网络性能。所以我们在性能调节中给NetPriority赋的一些值是:
- Actor - 1.0
- Pawns - 2.0
- PlayerController - 3.0
- Projectiles - 2.5
- Inventory - 1.4
- Vehicule - 3.0
复制
网络代码基于三个简单的、低级复制操作,这些操作用于在服务器和客户端间通信游戏状态信息:
服务器为每个客户端都定义了一组“相关的”参与者(这些参与者要么对客户端可见,要么可能会对客户端视图或即时运动有点影响),并且告诉客户端创建和维护一个参与者的“复制的”副本。但是服务器总是有该参与者的权威版本,在任何时刻,许多客户端都可能有那个参与者近似的、复制的版本。
当一个复制的参与者是在客户端上产生时,只有Location和Rotation(如果bNetInitialRotation设为true就是合法的)是在PreBeginPlay()和PostBeginPlay()期间合法的。复制的参与者只能被销毁,因为服务器会关闭它们的复制通道,但如果bNetTemporary和bTearOff属性已经被设为true那就例外了。
参与者属性复制是可靠的,这意味着参与者的客户端版本属性最终会反映出服务器上的值,不是所有的那些属性值变化都会被复制。在任何情况下,参与者属性都只能从服务器复制到客户端;如果参与者属性包括在定义了那个属性的Actor类的复制定义中,那么这样的属性就会被复制。
复制定义指定了复制条件,这些条件描述了在目前考虑情况下,何时以及是否需要将一个给定属性复制到客户端。即使一个参与者是不相关的,也不会复制它全部的属性。仔细指定复制条件可以充分地减少带宽使用。
在复制阶段只有三个参与这属性是合法的,根据客户端,为确定复制的服务器改变值:
- 如果任何复制的属性已经被UnrealScript改变,bNetDirty 为真,这不是用作优化(不需要检查UnrealScript复制条件,或者如果bNetDirty为假,不需要检查属性是否在已修改的脚本中被修改)。不要使用bNetDirty来管理频繁更新的属性!
- 直到所有参与者属性的初始复制都完成之后,bNetInitial 仍旧为真。
- 如果参与者的顶层拥有者是当前客户端拥有的PlayerController 的话,bNetOwner为真。
描述游戏状态方面的参与者可以被“复制”,这些方面对于客户端来讲很重要。也就是说,在服务器端无论何时该变量值改变,服务器都会向客户端发送更新的值。变量在客户端也有可能改变——在这种情况下新值会覆写原值。变量复制条件是在UnrealScript类的Replication{}块中指定的。
在网络游戏中在服务器上调用的函数可以被路由到远程客户端,而不是在本地执行。或者,客户端上调用的函数可以被路由到服务器,而不是本地调用。函数复制由服务器、客户端、可靠和不可靠的关键字在函数定义中指定。
举一个具体的例子,考虑在网络游戏中你是客户端的情况。你看到两个敌人跑向你,朝你射击,你听到他们的枪声。既然所有的游戏状态都是在服务器上而不是在你的机器上维护的,为什么你能看到和听到这些事情的发生呢?
- 你可以看到敌人,因为服务器已经认识到,敌人是与你相关的(即他们是可见的),服务器目前正在复制这些参与者给你。因此,你(客户端)有在追赶你的两个玩家的一个本地副本。
- 你可以看到敌人正在向你跑,因为服务器正在向你复制他们的位置和速度属性。在服务器更新位置之间,客户端会在本地模拟敌人的移动。
- 你可以听到他们的枪声,因为服务器在向你复制ClientHearSound函数。每当服务器确定PlayerPawn听到了声音,就会为此PlayerPawn 调用ClientHearSound函数。
因此,在这一点上,Unreal多玩家游戏操作的低层次机制就比较明确了。服务器正在更新游戏状态,做出所有大的游戏决定。服务器正在将一些参与者复制到客户端。服务器正在向客户端复制一些变量。服务器正在向客户端复制一些函数调用。
还应该清楚的是,并不是所有的参与者都需要被复制。例如,如果一个参与者在关卡路程的一半,远在你视线之外,你不需要浪费带宽发送更新。此外,所有的变量不需要更新。例如,服务器用来做AI决策的变量不需要发送给客户端,客户端只需要知道它们的显示变量、动画变量和物理变量。此外,在服务器上执行的大多数函数不应该被复制。只有那些能让客户端看到或听到一些东西的函数才需要被复制。因此,总的来说,服务器都包含了大量的数据,只有很小的一部分对客户端比较重要——那些影响玩家看到、听到或感觉的东西。
因此,下一个逻辑问题是,“UE如何知道哪些参与者、变量和函数调用需要复制?”
答案是,为参与者编写脚本的程序员负责确定脚本中需要复制哪些变量和函数。并且,他负责编写一块小的代码名为“复制语句”,在该脚本中,告诉UE需要什么条件下复制。对于现实世界的例子,考虑一些在Actor类中定义的东西。
注:并不是所有的这些变量都在UE3 的Actor.uc中(它们曾出现在UE1和2);但核心概念仍然是有效的。
•在人物类,还有一堆的布尔变量定义按键和按钮,如bfire和bjump。这些是在客户端(输入发生的地方)生成的,服务器需要知道它们。因此,复制条件基本上说:“复制这个如果我是客户端”。
- 位置变量(矢量)包含参与者的位置。服务器负责维护位置,所以服务器需要把位置变量发送到客户端。因此,复制条件基本上说:“如果我是服务器,复制这个它”。
· 网格变量(对象引用)引用了应该为参与者渲染的网格。服务器需要把网格变量发送给客户端,但是只有参与者是作为网格渲染的情况下才需要发送,也就是说,如果参与者的DrawType是DT_Mesh的话。因此,复制条件基本说:“如果我是服务器而且DrawType是DT_Mesh,复制它。
· 在PlayerPawn类中,有一大堆布尔型变量,这些变量定义了按键响应和按钮响应,如bFire和bJump。这些都是在客户端上生成的(输入在此发生),服务器需要了解这些。因此,这个函数的复制条件可能是“如果我是客户端,复制它”。
- 在PlayerController类中,有一个ClientHearSound函数,这个函数会告诉玩家他/她听到了一个声音。这个函数是在服务器上调用的,但是,当然了,声音需要被玩游戏的人确切听到,人是在客户端。所以这个函数的复制条件可能是“如果我是服务器,复制它”。
从上面的例子来看,应该注意以下几点。首先,每个可能被复制的变量和函数需要有一个“复制条件”连接到它,也就是说,一个表达式计算为true或false,取决于是否需要复制东西。其次,这些复制条件应该是双向的:服务器需要能够向客户端复制变量和函数,客户端需要能够将它们复制到服务器上。第三,这些“复制条件”可以是复杂的,如“如果我是服务器,复制,这是第一次通过网络复制这个参与者。”
因此,我们需要一个通用的条件表达方式(复杂),在该条件下应该复制变量和函数。表达这些条件最好的方式是什么?我们看了所有的选项,并认为Unrealscript已经是一个非常强大的语言创作类、变量和代码,会成为编写复制条件的完美工具。
在UnrealScript中,每个类可以有一个复制的语句。复制语句包含一个或多个复制定义。每个复制定义包括一个复制条件(一个计算为true或false的语句),以及条件适用的一个或多个变量的列表。
类中的复制语句只能引用该类中定义的变量。这样,如果Actor类包含一个变量DrawType,然后你知道在哪里寻找它的复制条件:它只能位于Actor类中。
类不包含复制语句是有效的;这仅仅意味着类不定义任何需要复制的新变量或函数。事实上,大多数类不需要复制语句,因为影响显示的“有趣”变量是在类中定义的,并且只由子类修改。
如果在类中定义了一个新变量,但不在复制定义中列出该变量,则意味着您的变量绝对不会被复制。这是规范,大多数变量不需要被复制。
这里是一个复制语句的Unrealscript语法例子,封闭在replication {}块中。这是从PlayerReplicationInfo类中提取出的:
PlayerReplicationInfo.uc
replication
{
// Things theserver should send to the client.
if (bNetDirty && (Role == Role_Authority) )
Score,Deaths, bHasFlag, PlayerLocationHint,
PlayerName, Team, TeamID, bIsFemale, bAdmin,
bIsSpectator, bOnlySpectator, bWaitingPlayer, bReadyToPlay,
StartTime,bOutOfLives, UniqueId;
if (bNetDirty && (Role == Role_Authority) && !bNetOwner )
PacketLoss, Ping;
if (bNetInitial && (Role == Role_Authority) )
PlayerID,bBot;
}
用unreliable关键字复制的函数不保证能够到达另一方,如果确实到达了另一方,它们可能是乱序收到的。唯一可以预防收到不可靠函数的方法是网络的包丢失和带宽饱和。所以,你需要理解这件怪事,这里我们将非常近似。在不同类型的网络中,结果变化很大,所以我们不能做任何保证:
- 局域网——在局域网游戏中,我们猜测,不可靠的数据在约99%的情况下都会成功接收。然而,在游戏过程中,成千上万的东西被复制,所以你可以肯定,一些不可靠的数据将丢失。因此,即使你只是针对局域网的性能,在复制函数在线缆上丢失的情况下,你的代码也需要处理不可靠复制函数。
- 互联网——在一个典型的低质量28.8k ISP连接中,大体上90% - 95%的不可靠函数会收到。换句话说,它经常丢失。
为了获得可靠的和不可靠的函数之间的权衡更好的感觉,检查Unreal脚本中的复制语句,并衡量它们的重要性vs.我们作出的可靠性决定。要谨慎,只有在绝对必要时才使用可靠函数。
即使在数据包丢失和带宽饱和的条件下,变量也总是保证最终到达另一方。这样的变量变化不能保证以它们发送的顺序到达另一方。此外,尽管变量的值最终将被同步,但不是值的每一个变化都可以被复制。
下面是类脚本中复制条件的简单示例:
Pawn.uc
replication
{
if(Role==ROLE_Authority )
Weapon;
}
这个复制的条件,翻译成英文是“如果这个参与者角色变量的值等于ROLE_Authority,那么这个参与者的Weapon变量应该被复制到所有的客户端,因为这个参与者是相关的”。
复制条件可能是计算为true或false(即布尔表达式)的任何表达式。所以,任何你可以写在UnrealScript中的表达式都可以,包括比较变量;调用函数;并使用布尔!,&&,| |,和^ ^运算符合并条件。
一个参与者的Role变量通常描述了本地机器对参与者的控制程度。ROLE_Authority意味着“本机是服务器,所以它对于代理参与者是完全权威的”。ROLE_SimulatedProxy意味着“本机是客户端,它应该模拟(预测)的参与者的物理”。在后面的部分中详细描述了Role,但快速总结如下:
- if (Role == ROLE_Authority) ——意味着“如果我是服务器,我应该把这复制给客户端” 。
- if (Role < ROLE_Authority) ——意味着“如果我是客户端,我应该把这复制给服务器”。
在复制语句中经常使用下列变量,因为它们具有很高的实用性:
- bIsPlayer ——参与者是否是个玩家。如果是就是true,是其他参与者就是false。
- bNetOwner——此参与者是否由正在评估复制条件的客户端所有。例如,假如说“弗莱德”拿着DispersionPistol,“鲍勃”不持任何武器。当DispersionPistol被复制到“弗莱德”时,其bNetOwner变量将变为true(因为“弗莱德”拥有武器)。当它被复制到“鲍伯”时,其bNetOwner变量将是false(因为“鲍伯”没有自己的武器)。
- bNetInitial——只在客户端合法(也就是说,如果 Role = ROLE_Authority)。显示此参与者是否第一次被复制到客户端。这对于Role = ROLE_SimulatedProxy的客户端是有用的,因为它让服务器就发送一次自己的位置和速度,客户端随后预测。
由于变量通常是单向复制的(从客户端到服务器,或从服务器到客户端,但绝不是两者同时),通常开始于Role或RemoteRole的比较:例如,if(Role == ROLE_Authority)或者if(RemoteRole< ROLE_SimulatedProxy)。如果复制条件不包含Role和RemoteRole的比较,有可能有点儿问题。
在网络运行过程中,服务器上的复制条件非常非常频繁地被评估。让复制条件尽可能的简单。
当复制条件允许调用函数时,尽量避免这样做,因为它可能会减速很多。
复制条件不应该有任何副作用,因为网络代码可以选择在任何时候调用它们,包括当你不期望的时候。例如,如果你做一些什么像if(Counter++ > 10)……,祝你好运,试着找出会发生什么!
变量复制
每次滴答后,服务器检查其相关集合中的所有参与者。所有复制的变量都被检查,看它们自上次更新以来是否已更改,并且对变量的复制条件进行评估,以查看是否需要发送变量。只要有带宽可用的连接,这些变量就可以通过网络发送到其他机器。
因此,客户端接收到世界上正在发生的重要事件的更新,这些事件在客户端可见或听到。关于变量复制的关键点是:
- 变量复制仅在滴答完成后发生。因此,如果在一个滴答的持续时间内,一个变量会变为一个新的值,然后它返回到它的原始值,那么这个变量就不会被复制。因此,客户只能在其滴完成后听到服务器参与者的状态在滴答过程中变量的状态对客户端是不可见的。
- 相对于变量之前的已知值,这些变量只会在它们改变时复制。
- 当变量在客户端的相关集合中时,它们只会被复制到客户端。因此,客户端没有不在参与者相关集合中的参与者准确变量。
UnrealScript没有全局变量的概念;所以只能复制属于一个参与者的实例变量。
- 矢量和旋转——为了提高带宽效率,Unreal量化了向量和旋转值。向量的X、Y、Z分量在发送之前被转换为16位有符号整型,因此任何超出-32768...32767范围的分数值或值都会丢失。旋转的Pitch、 Yaw、Roll组件转换为字节,形式为(Pitch >> 8) & 255。因此,你需要小心矢量和旋转。如果你必须有充分的精度,那么就使用int或float变量为个体组件使用;所有其他数据类型都以完整精度发送。
- 一般结构体——这些都是通过发送所有的组件复制的。结构体要么全发送,要么全不发送。
- 可以复制变量数组,但数组的大小(以字节为单位)必须小于448字节。无法复制动态数组。
- 数组可以被有效复制;如果大数组的单个元素发生更改,则只发送该元素。
注:复制规则可以改变,有些规则比其他规则优先级高。例如,结构体中的静态数组总是完整地发送!
参与者属性复制是可靠的。这意味着该参与者的客户端版本的属性将最终反映服务器上的值,而不是所有属性值更改都将被复制。
- 属性是从服务器复制到客户端。
- 只有当属性包含在定义了该属性的类的复制定义中时,它们才会被复制。
函数调用复制
当在一个网络游戏调用UnrealScript函数时,并且该函数含有一个复制关键字,关键字会被评估,执行过程如下:
函数调用被发送到网络连接另一端上的机器来执行。也就是说,函数的名字,和其所有参数,都一起填进数据包中,之后传输到另一台机器上用于之后的执行。当这件事发生时,函数会马上返回,执行继续。如果函数被声明有返回值,那么它的返回值会被设为0(或者相当于零的其他类型,即:0,0,0向量,空对象,等等)。任何输出参数都不受影响。换句话说,UnrealScript从不坐等复制函数的调用完成,所以它不会死锁。发送复制函数调用来让远程机器执行,本地代码继续执行。
与复制变量不同,参与者上的函数调用可以从服务器复制到拥有该参与者的客户端(玩家)。所以,复制函数只在PlayerController的子类中有用(即玩家,拥有它们自身的),Pawn (即玩家的化身,是由一个控制它们的控制器所有),还有Inventory的子类(也就是说,武器和拾取物品,它们是正在带着它们的玩家所有的)。也就是说,函数调用只能被复制给一个参与者(拥有它的玩家);它们不进行组播。
如果在客户端调用带有server关键字标记的函数,它将被复制到服务器上。相反,当在服务器上调用带有client关键字的函数时,它将被复制到拥有那个参与者的客户端。
与复制变量不同的是,复制函数一被调用,这些调用就会发送到远程机器,它们总是被复制而且不考虑带宽。因此,如果复制函数调用过多,可能会淹没可用带宽。只要有可用带宽,复制函数都会吸走,然后剩下的带宽用于复制变量。因此,如果用复制函数淹没连接,变量复制可能没有足够的带宽,视觉上这会导致看不到其他参与者更新,或者在一个非常剧烈的运动中看到参与者更新。
在UnrealScript中,没有全局函数,所以没有“复制的全局函数”的概念。一个函数总是在一个特定的参与者的上下文中调用。
太多复制函数可以淹没可用带宽了(因为这些函数总是复制,不管可用带宽有多少),复制变量会自动根据可用带宽节流和分配。
当函数实际被调用时,函数调用只在UnrealScript执行期间复制,但是变量只在没有脚本代码执行时在当前“滴答”的末尾复制。
参与者上的函数调用只会复制到拥有该参与者的客户端,但是参与者的变量会复制到所有与该参与者相关的客户端。
在客户端上,许多参与者以“代理”的形式存在,这意味着服务器创建了参与者的近似副本,再发送到客户端,为客户在听觉上、视觉上提供游戏运行阶段听到和看到的合理近似值。
在客户端上,这些代理参与者经常用客户端物理四处游走,并且影响环境,所以在它们的函数可能在任何时刻被调用。例如,一个模拟代理TarydiumShard炮弹可能遇到一个自治代理Tree参与者。当参与者们发生碰撞时,UE尝试调用它们的Touch()函数来通知碰撞的发生。根据上下文,客户端想要执行其中一些函数调用,但是忽略另外一些。例如,Skaarj的Bump()函数不应该在客户端调用,因为他的Bump()函数想要执行游戏运行逻辑,而游戏运行逻辑应该在服务器上发生,所以Skaarj的Bump()函数不应该被调用。但是,应该调用TarydiumShard炮弹的Touch()函数,因为它阻止了物理,并且生成了一个客户端特效参与者。
UnrealScript函数可以选择性地用simulated关键字来声明,让程序员控制哪些函数应该在代理参与者上执行有良好粒度。对于代理参与者来说(也就是说,Role == ROLE_SimulatedProxy的参与者),只会调用带有simulated关键字声明的函数,其他函数都会跳过。
这是一个典型模拟函数的例子:
simulated function HitWall( vector HitNormal, actorWall )
{
SetPhysics(PHYS_None);
MakeNoise(0.3);
PlaySound(ImpactSound);
PlayAnim('Hit');
}
所以,simulated意味着“这个函数应该总是为代理参与者执行。”
注:确保模拟函数的子类的实现在其定义中也有simulated关键字!当发生这种情况时,Unrealscript的编译器产生警告。
模拟状态类似于模拟函数。
在UE和搭载Epic的游戏中,通用复制模式的目标是:
- 最小化复制参与者的代价
- 最小化潜在的复制的参与者数量(RemoteRole != ROLE_None的那些)
- 最大限度地减少需要检查每个客户端相关性的参与者数量。
- 尽量减少任意给定“滴答”下每个客户端实际关联的参与者数量
- 尽量减少任意给定“滴答”下每个客户端需要为每个复制参与者检查的复制属性的数量。
- 避免不必要地设置bNetDirty。
- 最小化参与者“滴答”代价
- 避免在服务器上生成不必要的参与者(粒子效果 )。
- 如果没有游戏运行相关性,避免执行代码。
- 尽量减少处理收到的复制函数的代价
- 尽量减少收到函数和需要处理的数量。
由于玩家数量增加,复制参与者的成本是服务器执行时间的主要组成部分,因为它往往以几何级数增长而不是与玩家的数量呈线性关系(因为一些潜在的复制行为往往与玩家的数量测成比例)。
- 尽量减少带宽使用
- 每个客户端的相关参与者数量
- 属性更新的频率
- 发送的数据包数
Unsuppress DevNetTraffic来查看所有复制参与者和属性的日志。控制台命令Stat Net也是有用的。使用网络探查器检查UE发送和接收的数据包也很有用。
客户端根据玩家输入预测客户端拥有的行为;在收到服务器确认之前,模拟这种行为(如有必要进行纠正)。我们将这个模型用于Pawn运动和Weapon处理,而不是用于Vehicle,由于节省带宽和物理模拟的复杂性比Vehicle处理减少延迟的好处重要,其中典型的网络响应延迟与典型的真实世界的Vehicle控制响应延迟没那么不同。
ReplicationInfo类将bAlwaysRelevant设置为true。服务器的性能可以通过设置一个较低的NetUpdateFrequency改进。每当一个复制的属性发生变化,明确改变NetUpdateTime来进行强制复制。服务器的性能也可以通过将bSkipActorPropertyReplication和bOnlyDirtyReplication设为true真正改进。
当复制有Repnotify关键字标记的属性时,会调用ReplicatedEvent()事件,修改的属性作为参数名。系统提供了一种高效的方法来初始化多个属性或者组件,这种方法是基于单个的复制属性更新。比如说,当Vehicle.bDriving改变了,这会被ReplicatedEvent()事件捕捉到,该事件之后会调用DrivingStatusChanged()。我们在UT中使用这种方法启动或关闭引擎声音和其他客户端特效。相似地,当一个UTCarriedObject收到了team属性时,它会更新客户端特性,改变应用于网格的材质这样的组件属性,或者动态光颜色。
当在客户端本地PlayerController拥有的PlayerReplicationInfo更新它的团队属性或所有者属性时,它会调用所有参与者上的NotifyLocalPlayerTeamReceived()。
它也可以用于延迟初始化代码的执行,直到所有所需的属性都已经被复制。注意,即使属性没有从默认值发生改变,也不会有复制事件,所以你需要确定参与者在这种情况下已经正确初始化。
在一个联网游戏中,每个游戏世界实例都有一个NetMode。WorldInfo类定义了ENetMode枚举和相关的NetMode变量,如下:
varenum ENetMode
{
NM_Standalone, // Standalone game.
NM_DedicatedServer, // 专用服务器,没有本地客户端
NM_ListenServer, // 监听服务器
NM_Client // 只有客户端,没有本地服务器
}NetMode;
NetMode属性经常用于在不同游戏实例类型上控制哪些代码。
GameInfo类实现了游戏规则。服务器(专用的和单玩家的)有一个GameInfo子类,在UnrealScript可以作为WorldInfo.Game访问到。对于Unreal中的每种游戏类型,都有一个特殊的GameInfo子类。例如,一些现有的类是:UTGame, UTDeathmatch, UTTeamGame。
网络游戏中的客户端没有GameInfo,也就是说,客户端上有WorldInfo.Game ==None。客户端不应该期望拥有GameInfo,因为服务器实现了所有的游戏运行规则,而且客户端的大部分代码调用都不知道游戏规则是什么。
GameInfo实现了一系列广泛的功能,如识别的玩家来来去去,为死亡分配credit,确定武器是否应该重生,等等。在这里,我们只会着眼于与网络编程直接相关的GameInfo功能。
事件InitGame(string Options, out stringErrorMessage);
当服务器(无论是网络游戏还是单人游戏)首次启动时调用。这给了服务器解析启动URL选项的机会。例如,如果服务器用“Unreal.exe MyLevel.unr?game=unreali.teamgame”启动,Options字符串是"?game=unreali.teamgame"。如果将Error设置为非空字符串,则游戏会出现严重错误。
事件PreLogin(string Options, stringAddress, out string ErrorMessage, out string FailCode);
在网络客户端登录前立即调用。这可能会让服务器拒绝玩家。这是服务器应该验证玩家的口令(如果有的话)的位置,还要加强玩家限制,等等。
事件 PlayerController Login(stringPortal, string Options, out string ErrorMessage);
Login()函数总是在调用PreLogin()不返回错误字符串之后调用。它负责生成玩家,使用Options字符串中的参数。如果成功的话,它应该返回它生成的PlayerController参与者了。Login()和PostLogin()也被用来在一个单机游戏创造PlayerController参与者。
如果Login()函数返回None,那就意味着登录失败了,那么他就应该给字符串设置一个错误信息来描述错误。Login()失败应该尽量避免发生,如果你要让一个登录失败,在中PreLogin()失败比Login()高效。
事件 PostLogin(PlayerControllerNewPlayer);
PostLogin()函数在成功登录之后调用,这是复制函数可以被调用的第一个点。
综述
如果一个纯粹的客户端/服务器模型应用于Unreal,玩家的运动会延迟。在300毫秒的ping连接上,当你按下前进键,你不会看到自己移动300毫秒。当你把鼠标向左推,你不会看见自己转了300毫秒,这会非常令人沮丧。
为了消除客户的运动滞后,Unreal使用了类似于QuakeWorld最先提出的预测方案。必须提到的是,玩家预测方案是在UnrealScript全面实现的。这是一个在PlayerController类实现的高层次功能,而不是网络代码的功能:Unreal的运动预测是完全在网络代码的通用复制功能上分层的。
你可以清楚地看到Unreal如何通过检查PlayerController脚本进行玩家预测。由于代码有点复杂,在这里简要介绍了它的工作原理。
该方法可以被最好地描述为一个锁步预测/校正算法。客户需要考虑他的输入(操纵杆,鼠标,键盘)和物理力(重力,浮力,区域速度),并作为一个三维加速度矢量描述了其运动。客户端用各种与输入相关的信息和当前时间戳(客户端上WorldInfo.TimeSeconds的当前值)将加速度发送到服务器,这些都在复制ServerMove函数中调用:
服务器函数ServerMove(float TimeStamp, vector InAccel, vector ClientLoc, byteMoveFlags, byte ClientRoll, int View)
然后,客户端在本地调用MoveAutonomous())进行相同的运动,他用SavedMove类将运动存储在记忆运动的链表中。正如你所看到的,如果客户端从未从服务器上听到任何东西,客户端就可以像一个单人游戏一样在零延迟的情况下移动。
当服务器接收到一个ServerMove()函数调用时(通过网络复制),服务器立即执行服务器上的相同的运动。从当前ServerMove的TimeStamp和以往的TimeStamp推导出运动的DeltaTime。这样,服务器与客户端执行相同的基本运动逻辑。但是,服务器可能会看到与客户端稍有不同的情况。例如,如果有一个怪物运行,客户端可能会认为它是在与服务器不同的位置上(因为客户端只是与服务器粗略近似同步)。因此,客户端和服务器可能不同意客户实际移动所造成的ServerMove()调用。无论如何,服务器是权威的,他完全负责确定客户端的位置。一旦服务器处理客户端的ServerMove()调用,它就会调用客户端的ClientAdjustPosition()函数,这个函数是通过网络复制到客户端的:
客户端函数ClientAdjustPosition(float TimeStamp, name newState, EPhysicsnewPhysics, float NewLocX, float NewLocY, float NewLocZ, float NewVelX, floatNewVelY, float NewVelZ, Actor NewBase)
现在,当客户端收到了ClientAdjustPosition() 调用,他必须尊重服务器对于他位置的权威性。所以,客户端通过ClientAdjustPosition()指定的内容设定其确切的位置和速度。然而,服务器在ClientAdjustPosition()中指定的位置在反映了客户在过去的一段时间的实际位置。但是,客户想预测他现在应该在哪里。所以,现在客户端浏览其链表中所有的SavedMove。舍弃所有比ClientAdjustPosition()调用的TimeStamp早的移动所有在TimeStamp之后发生的移动之后都会通过循环和为每个移动调用MoveAutonomous()重新运行。
这样,在任何时间点,客户端总是比服务器告诉他的提前预测,提前时间大约是ping时间的一半。而且,他的本地运动也一点也不延迟。
这种方法是纯粹的预测,它给出了两全其美结果:在所有情况下,服务器仍然完全权威。几乎在任何时候,客户端运动模拟都准确地反映了服务器进行的客户端运动,所以很少纠正客户端的位置。只有在罕见的情况下,如玩家被火箭击中,或撞到敌人时,客户的位置才需要纠正。
下面的图表帮助说明服务器和客户端上的移动模式,包括错误调整。
服务器 | 客户端 | |
ReplicateMove() | ||
代替ProcessMove()被调用。基于对玩家输入进行Pawn物理更新,保存(在PlayerController SavedMoves中)并复制了结果。SavedMove可以作为子类来保存游戏特定运动输入和结果。ReplicateMove() 也试图合并复制的移动来节省上行带宽,提升服务器性能。 | ||
ServerMove() | <- | CallServerMove() |
根据接收到的输入执行Pawn物理更新,并将结果与客户端发送的结果进行比较。注意运动更新是基于客户端的时钟。如果客户端积累了明显的位置错误,请求更正。否则的话,请求良好动作的ack信息。 | 用客户端时钟的时间戳发送一个或两个当前动作(取决于帧速率和可用带宽)。每次发送两个动作可以节省带宽,但会增加更正的延迟。也可能调用OldServerMove()来重新发送数据包丢失的情况下的最“重要”的动作。 | |
SendClientAdjustment() | -> | ClientAckGoodMove() |
如果多个ServerMoves()收到了这个“滴答”,客户端响应会延迟到PlayerController的“滴答”结束,避免发送多个响应。如果没有错误,确认是良好动作。 | 更新ping,基于时间戳的往返时间,用较早的时间戳清除savedmoves。 |
服务器 | 客户端 | |
SendClientAdjustment() | -> | ClientAdjustPosition() |
如果多个ServerMoves()收到了这个“滴答”,客户端响应会延迟到PlayerController的“滴答”结束,避免发送多个响应。如果没有错误,确认是良好动作。 | 用修正时间戳之前的时间戳清除savedmoves。移动Pawn到服务器指定的位置,并设置bUpdatePosition。 | |
ClientUpdatePosition() | ||
当bUpdatePosition 为真时来自PlayerTick()。回放所有优秀的savedmoves,让Pawn回到了当前客户端时间。 |
PlayerController代码认为客户端和服务器总是尝试运行完全相同的状态; ClientAdjustPosition()包含了状态,这样一来,如果进入了不同状态,就可以更新客户端。在这种情况下:服务器需要改变状态但是客户端不能自己模拟该状态时,ClientGotoState()用于强制客户端立即进入那个状态。不支持处理/同步UnrealScript的状态栈功能(PushState() / PopState()),而且我们不建议将其用于PlayerController。
如果动画与游戏运行无关,它就不需要在服务器上执行。SkeletalMeshComponent的bUpdateSkelWhenNotRendered和IgnoreControllersWhenNotRendered属性可以控制这些,也不需要在带有SkelControlBase::bIgnoreWhenNotRendered的每个骨骼控制器基础上运行。客户端动画是受Pawn状态检查驱动的(物理,Pawn属性)。
对于动画驱动的运动,根骨运动被转换成加速度/速度,这就是被复制。因此,动画仍然锁定到位(相对于参与者),但根骨运动被转移到加速/速度移动参与者。
这对于服务器/客户端来讲不比非根运动移动沉重。
如果bTearoff为真,那么这个参与者就不再被复制到新的客户端,而且还会从已经复制了它的客户端上移除(变成ROLE_Authority)。TornOff()事件是在收到bTearOff时调用。默认的实现会在死亡的Pawn上调用PlayDying()。
武器射击与玩家移动的模式相似:
- 玩家一旦输入请求射击,客户端立即产生射击效果(声音、动画、muzzleflash),调用ServerStartFire()和ServerStopFire()请求服务器射击。
- 客户有足够的状态信息(弹药数、武器定时状态等)来正确预测武器是否可以发射,除了在极少数情况下,客户端和服务器版本的相关属性不同步。
- 服务器会生成射弹/破坏武器(生成射弹),射弹复制到客户端。
这个例子对于简单的、可预测的射弹是有用的:
- bNetTemporary被设为真。
- 初始复制后,参与者通道关闭,参与者不再更新。参与者将被客户端销毁。
- 节省带宽和服务器属性复制测试。
- bReplicateInstigator被设为真。
- 所以射弹可以与煽动者正确交互。
- 客户端特效生成
- 注意客户端上生成的参与者在此客户端上有ROLE_Authority,服务器和其他客户端上没有。
- 这些特效不需要在服务器上生成,也不需要复制。
缺点:如果目标和/或射弹的客户端模拟结束了,可能误击中或错过目标。不要为此使用单射杀型射弹。
避免让相互关联的几组参与者都复制,是考虑到性能和尽量减少同步问题。
在Unreal Tournament中,武器只复制给拥有它的客户。武器附件不复制,但通过一些复制的Pawn属性在客户端生成和控制。Pawn复制FlashCount和FiringMode,UT Pawn复制CurrentWeaponAttachmentClass。Pawn 中的ViewPitch属性是该模式的另一个应用案例。
函数ClientHearSound() 在每一个听得见声音的PlayerController 调用。函数ClientCreateAudioComponent() 在负责声音的参与者上调用。如果参与者不在客户端上,声音会在复制的地方播放,其中音频组件由WorldInfo创建。PlayerController中的函数ClientPlaySound() 在客户端播放非定位声音。
试着尽可能在客户端模拟声音!
复制
物理模拟在客户端和服务器上运行。更新从服务器发送到客户端。下面的结构体是用来描述一个刚体的物理状态,并且被复制(如在Actor中所定义的):
structRigidBodyState
{
var vector Position;
var Quat Quaternion;
var vector LinVel; // RBSTATE_LINVELSCALEtimes actual (precision reasons)
var vector AngVel; // RBSTATE_ANGVELSCALEtimes actual (precision reasons)
var int bNewData;
};
使用了结构体,所以所有属性都会同时改变。向量被压缩为整数分辨率,以便在发送之前缩放它们。Quats被压缩到只发送三个值;第四个值是从其他三个推出来的。
对于物理复制,有两种类型的校正:
- 小校正和物体移动:20%位置调整,80%额外的相对于目标的速度。
- 大校正或对象停止:100%位置调整。
下面的场景描述了物理模拟:
- ROLE_SimulatedProxy 参与者模拟
- 客户端根据接收到的位置和速度不断更新模拟的参与者位置。
- 如果bUpdateSimulatedPosition 为真, 权威的位置更新不断从服务器发送到客户端(否则,在参与者初始复制之后不会发送位置更新)。
- 其他客户端上的Pawns
- 不像其他的参与者,模拟Pawn不在客户端执行正常的物理功能。这意味着物理事件,如Landed()事件,都不会在非拥有的客户端上为Pawn所调用。
- Pawn的物理方式是从它的位置和bSimulateGravity标志推断,其位置预测是基于复制的速度更新。
- 设置了bSimGravityDisabled标志,如果Pawn在复制的位置不合适,并在客户端有整个世界失败的风险,那就暂时关闭重力模拟。
- PHYS_RigidBody 参与者(车辆,K参与者等)
- 客户端和服务器都模拟对象,但服务器定期向客户端发送权威更新(当对象处于唤醒状态时)。然后客户端移动对象以匹配服务器版本。
- 如果错误是低于一个可接受的阈值,通过改变速度带引出位置上的收敛速度,而不是捕捉的位置,试试这样做,很容易。
- 当所有属性都必须同步接收时,为原子复制使用RigidBodyState结构体。
- 客户端和服务器都模拟对象,但服务器定期向客户端发送权威更新(当对象处于唤醒状态时)。然后客户端移动对象以匹配服务器版本。
对于Radoll物理,只复制髋关节位置。它往往可能完全排除,而不是复制所有。
对于Vehicle(PHYS_RigidBody参与者),有下述网络流:
- 在客户端按键
- 向服务器发送输入(油门、转向、上升)——复制ServerDrive调用的函数
- 生成输出(OutputBrake, OutputGas等);打包进复制结构体,这些结构体可以发送到客户端——服务器调用ProcessCarInput()
- 在服务器和客户端上更新车辆;用输出(OutputBrake, OutputGas等)将力/力矩应用到车轮/车轮——在服务器和客户端上调用UpdateVehicle()
这里的目标是在给定带宽限制下,最大化可见重要细节的数量。在运行时确定的带宽限制,为多玩家游戏中的参与者编写脚本的目的是保持最低限度的带宽使用。我们在脚本中使用的技术包括:
尽可能使用ROLE_SimulatedProxy和模拟运动。例如,几乎所有的虚幻的炮弹都使用ROLE_SimulatedProxy。Razorjack alt-fire叶片是一个例外,玩家可以在游戏中控制它,因此服务器必须不断向客户端更新位置。
对于快速特效,只在客户端生成特效参与者。例如,我们的许多炮弹使用模拟HitWall()函数在客户端生成特效。由于这些特殊效果只是装饰,而不会影响游戏,所以全在客户端完成这些事没有弊端。
当带有Repnotify关键字的属性被复制时, ReplicatedEvent()事件会被调用,修改属性的名称作为参数。查看复制模式部分,以了解如何节省网络带宽。
微调每个类默认的NetPriority。炮弹和玩家需要有高度的优先级,纯粹的装饰效果可以有较低的优先级。Unreal提供的默认值作为第一遍的猜测是不错的,但你总能通过微调这些值获得一些改进。
当一个参与者第一次复制到客户端时,所有变量都初始化为类默认值。随后,只有与最近的已知值不同的变量被复制。因此,您应该设计您的类,以便尽可能多的变量自动设置为它们的类默认值。例如,如果一个参与者总要有一个值为123的LightBrightness,有两种方法可以做到:(1)将LightBrightness的类默认值设为123,或(2)在参与者的BeginPlay()函数中,将LightBrightness初始化为123。第一种方法是更有效的,因为LightBrightness值不需要被复制。在第二种方法中,每当参与者第一次与客户端相关时,都需要复制LightBrightness。
也要注意以下情况:
- 如果参与者引用不可序列化,不清除bNetInitial 和 bNetDirty(因为它与客户端不相关)。这意味着服务器将继续尝试复制属性,花费CPU周期成本。
在Unreal Tournament中,我们遇到了以下几种与网络有关的欺骗:
- 加速
- 利用我们使用客户端的时钟进行运动更新的事实。
- 内置检测:通过验证客户端和服务器时钟不以不同的速率移动。
- 误报与大量的数据包丢失
- Aimbots——UnrealScript和外部版本
- 墙黑客和雷达——UnrealScript和外部版本
- 加速
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;