最近在网上发现了一篇关于UE网络入门的文章(原文在文末给出),对UE的网络做了较为系统的陈述,这里将学习到的一些要点整理出来,方便后续回顾并不断加深理解。
原文的概念都是基于UE4.14给出的,跟最新的代码会有一些差异,不过不影响对UE网络方案的整体理解,后续会随着工作与学习的深入,也会对这里面过时的内容进行更新,使之保持在一个较为updated的状态。
此外,原文在介绍每个概念的时候,会辅以具体的案例(代码片段、蓝图截图等)加以说明,因为相对较为简单,就没有直接搬运过来,如果理解上存在困难可以移步原文进行对照阅读。
1. 概述
UE使用的是一个标准的C/S架构,即服务器上的数据具有最高解释权,客户端的操作(比如移动,或者发送消息给其他客户端)需要先发送给服务器,经过服务器校验之后才会同步给其他客户端。
联网游戏开发的一个基本要求就是,凡是跟玩法挂钩的逻辑,都要走服务器校验,不要相信客户端,否则就会导致作弊,从而影响游戏的平衡,严重的话甚至会威胁到产品的生命力。
2. 网络框架
根据Actor的出现位置(C/S)不同,我们可以将Actor(Object)分成如下的四类,而后面的陈述则是基于这种划分来展开的:
- Server Only:这类Objects只在Server上存在,如GameMode
• Server & Clients:这类Objects在Server跟所有的Clients上都存在,如GameState,PlayerState,Pawn
• Server & Owning Client :这类Objects只在Server以及Owning Clients(这类Clients指的是拥有Actor的Player或者Client,即Actor的Owner是对应的Player)上存在,如PlayerController
• Owning Client Only:这类Objects只存在于Clients上面,如HUD、UMG等。
如上图所示:基于上述划分,我们可以将GamePlay中的关键Class塞到对应的归类中。
转换成MultiPlayer的视图,可以用如下的图形表示:
需要注意,多个Client之间并无Actor的共享。
2.1 常用GamePlay Classes
下面对GamePlay中常用的Class做一下简单阐述。
Game Mode
在UE4.14之前,Game Mode Class有比较重的逻辑,但是并不是所有游戏都需要这部分逻辑,因此在UE4.14中对其进行了拆分,拆解成更轻量更通用的GameModeBase Class以及作为使用Sample的GameMode Class。
AGameMode Class主要用于定义游戏的规则,包括基础的GamePlay Class如APawn、APlayerController以及APlayerState等,这个Class只在DS上存在,在客户端获取对应的指针得到的是空。Game Mode中,我们可以设定如下的游戏规则:
- 是否需要组队
- 决胜条件或者游戏结算规则是什么
- 积分规则是什么
- 以及角色与武器的约束等
借用GameMode我们可以定义不同的游戏规则,比如DeathMatch Mode、Capture Flag Mode等。
为了对游戏规则进行自定义,GameMode提供了一系列的虚函数供用户重写:
- PlayerCanRestart
- OnLogOut
- ReadyToStartMatch
- InitStartSpot等
同时,GameMode还提供了一系列自定义的事件供用户监听并做出响应:
- Event OnPostLogin:每当有Player连接上来就会触发
此外,GameMode本身也带有一系列的成员变量用于提供一些自定义的开关或者控制逻辑:
- DefaultPlayerName,指定Player连接上来时的默认名字
- bDelayStart,用于判断是否需要延迟启动,即使ReadyToStartMatch已经返回true了,依然可以做到一定的延迟启动
Game Mode可以通过C++或者蓝图进行改写或者重定义,其中的C++函数名字跟蓝图的节点名字之间有比较直观的关联,虽然不一致,但是可以比较容易辨认,在使用的时候稍加注意即可。
Game State
为了实现游戏数据存储,保证流程的有序执行,GameMode通常会跟GameState Class一起工作。跟Game Mode一样,GameState在UE4.14也做了拆分,拆成了GameStateBase跟GameState两个Class。
如果要想实现Server跟Client之间的数据交换,GameState将会是一个非常关键的Class,这个Class中存储的主要是跟游戏(而非角色)相关的数据(比如游戏的当前状态,举个例子,通过MatchState函数可以拿到比赛状态数据;又比如角色的相关数据,如角色的击杀数),包括APlayerState列表等。这个Class会被复制给所有的客户端(方便客户端展示得分之类),这也使得这个Class成为多人游戏中的一个非常关键的类。
以实际的例子来说明,假如GameMode定义游戏规则为达到3人击杀即获胜,那么GameState就需要存储每个角色的击杀数用于辅助判定,为了实现规则的自由灵活定制,GameState中存储的数据内容完全由用户自己掌控。
GameState中可用的一些数据有:
- PlayerArray,这个数据不是直接复制给客户端,而是在构造Player的时候,会在其中自动添加PlayerState,只有PlayerState是会被复制给客户端的,PlayerState数据是在创建的时候通过一次性的方式搜集起来的。这里需要说明的是,虽然在DS跟客户端上,PlayerState到GameState的添加都是通过AGameState::AddPlayerState接口完成的,但是在DS上是通过AGameState::PostInitializeComponents接口完触发的,而在客户端上是通过APlayerState::PostInitializeComponents触发的(基于时序关系可以很好理解)。
- MatchState
- ElapsedTime
这些数据都会从DS复制到客户端,除此之外,还有一些只存在于DS的数据: - AuthorityGameMode(指定游戏的GameMode)
Player State
GameState存储游戏数据与状态,PlayerState存储已经连接到DS的角色数据与状态(名字、得分等),这个类跟角色(玩家)是一一对应的,且会被复制给所有的客户端(用于展示数据),如果想要获取某个PlayerState数据,可以通过GameState的PlayerArray拿到。
PlayerState中的数据在切换关卡以及断网的时候都是有效的(同时UE还支持在这两种情况发生的时候,从老的PlayerState中拷贝数据到新的PlayerState,可以参考PlayerState的CopyProperties以及OverrideWith接口,这两个接口都支持重载)。
Pawn
Pawn是玩家实际操控的Actor,大部分时候是人形角色,需要的话,也可以是宠物型角色(猫狗等),PlayerController一次只能控制一个Pawn,但是可以通过Possess与UnPossess(这个行为逻辑通常是在DS上执行的)在多个Pawn之间切换。
Pawn有一个ACharacter的子类,这个子类有一个已经添加了联网(属性复制)功能的UMovementComponent组件,这个组件会负责将Pawn的Transform等信息同步给其他Client(包括Server,实际上不是Client上的Character移动,之后将数据同步给其他客户端,而是Server上的数据更新了之后同步给所有客户端,只是主控客户端会有一个本地的预表现)
PlayerController
PlayerController是运行时客户端Own到的第一个Actor,你可以将之理解成玩家的输入(这里的输入不是鼠标键盘等的输入,这部分输入通常是放在Pawn中)Actor,在实际运行时会用作玩家跟DS之间连接的桥梁,每个客户端有且只有一个PlayerController,这个PlayerController只存在于客户端本地与DS上,不存在于其他客户端上。
玩家的所有输入都会先传给PlayerController,如果PlayerController不做处理,就会自动向下传递给其他的Class进行处理,这些Class可以根据输入做对应的反应,当然也可以什么都不做,或者直接Deactivate这些输入,屏蔽进一步传递等
PlayerController的获取可以通过'GetPlayerController(0)' 或者'UGameplayStatics::GetPlayerController(GetWorld(), 0);'实现,但是这两个调用在客户端跟服务器上有不同表现:
- 在Listen server上,这个返回的是Listen server的PlayerController
- 在客户端上,这个返回的是客户端的PlayerController
- 在DS上,返回的是第一个客户端的PlayerController(如果对传入的参数进行调整,比如将0改成1,就会得到其他客户端的PlayerController)
为什么我们需要PlayerController,以及为什么PlayerController如此重要,是因为我们需要让客户端Onw一个Actor,通过这个Actor完成客户端触发的一系列RPC。
HUD
AHUD是客户端专属的Actor,这个Actor通常是放在PlayerController中的,系统会自动完成这个Actor的Spawn,在UMG(Unreal Motion Graphics)发布之前,HUD会负责UMG中的文本、贴图以及其他客户端视角的内容的绘制工作,而现在大部分UI绘制工作都由UMG来完成了。
在实际工作中,依然可以使用HUD来进行调试,或者单独划分一块使用情景,用HUD来完成Widgets的创建、显隐以及销毁等逻辑。
UMG
UMG继承自Slate,这是一套在C++中创建UI的编程语言,这套语言也是UE的编辑器创建所使用的语言。
Dedicated Server vs Listen Server
Dedicated Server(DS)是一个不需要客户端就能独立运行的程序进程,客户端可以自由加入或者连接到DS上。DS可以编译出Windows或者Linux版本,没有视觉部分,因此UI、Character动画等数据都不需要。
Listen Server(LS)本身既是服务器也是客户端,也就是说,这个Server任何时刻至少有一个客户端与之相连,当这个客户端断开连接,也就意味着服务器被销毁。跟DS一样,其他客户端也可以通过ip连接到LS上,不同的是LS的ip就是其本身客户端所在的ip,那么这个ip是会变化的,这个对连接造成了一些影响,不过通过后面说的onlineSubsystem可以解决这个问题。
Network Mode | Description |
---|---|
Standalone | 就是以Server模式来运行,不接受任何来自别的客户端的连接 |
Client | 就是以Client模式来运行,不会执行服务器端的任何逻辑代码 |
Listen Server | 以Server模式来运行,也会接受来自其他客户端的网络连接(connections)而且存在一个本地玩家(Local Player) |
Dedicated Server | 以Server模式来运行而且也会接受来自其他Client的连接,但是不存在本地玩家。所以这个模式下可以忽略画面,声音,用户输入,或者其他用户相关的特性,以此来提高Server的执行效率。这也是非常多的多人 游戏会采取的网络模式。 |
Replication
Replication(值复制)是服务器将数据或信息同步给客户端的一种行为。能够完成Replication的Class是AActor,所有继承自AActor的Class也就自带了此能力,当然,也不是所有的Actor都会有此行为,比如GameMode由于只存在于服务器上,所以也不需要这项能力。
由Server创建的Actor在打开bReplicates开关之后,就会自动完成对应数据到所有Client的同步,而如果Actor本身是在客户端上创建的,那么这个Actor就只能存在于本地客户端上。
-
Component Replication
- 大多数Component不会进行Replicate,更常用的做法是将Replicate功能放在Actor中,经过Actor的同步之后完成对应Component的属性变更与修正,但是也有情况Component需要跳过Actor直接进行同步(overhead比较低,只需要在Actor的Replication中添加一个额外的4字节的NetGUID head以及接近1字节的footer,其他的消耗就跟直接将属性放Actor上一样了)
- 对这个概念进行扩展,实际上不只是Actor的Components支持Replication,Actor的所有SubObject都支持Replication,非Component的Actor的SubObject想要实现Replication只需要实现如下的函数即可,在这种机制下,可以对一个背包下的所有item进行同步,只需要这个item继承自某个基类,不再需要所有的item都是Actor(这个资源消耗过高)
/** FActory method for instantiating templatized TobjectReplicator class for subobject replication */ virtual class FObjectReplicatorBase * InstantiateReplicatorForSubObject(UClass *SubobjClass); /** Method that allows an Actor to replicate subobjects on its Actor channel */ virtual bool ReplicateSubobjects(class UActorChannel *Channel, class FOutBunch *Bunch, FReplicationFlags *RepFlags); /** Called on the Actor when a new subobject is dynamically created via replication */ virtual void OnSubobjectCreatedFromReplication(UObject *NewSubobject);
- 在Actor进行Replication的时候,会触发其下Component的Replication,Component的Replication跟Actor的Replication逻辑相似,同样有属性复制(值复制)与RPC两种,同样需要在Component中添加::GetLifetimeReplicatedProps (...)说明
- 两类Component
- Static Components,随Actor一起创建的Components
- 这类Component在客户端的创建不需要DS额外通知
- 会作为Actor的Default SubObject存在
- Dynamic Components,在Actor创建后手动添加的Components
- Components的创建与添加需要通过DS同步给客户端来完成
- 当然,客户端也可以Spawn一些Components,这些Components不需要(也不能够)同步给其他客户端(或DS)
- Static Components,随Actor一起创建的Components
- 需要打开同步开关:AActorComponent::SetIsReplicated(true)
- 大多数Component不会进行Replicate,更常用的做法是将Replicate功能放在Actor中,经过Actor的同步之后完成对应Component的属性变更与修正,但是也有情况Component需要跳过Actor直接进行同步(overhead比较低,只需要在Actor的Replication中添加一个额外的4字节的NetGUID head以及接近1字节的footer,其他的消耗就跟直接将属性放Actor上一样了)
-
Actor Replication
- Actor是Replication的workhorse,通过定期更新(属性同步)以保持数据的一致
-
Actor Role and RemoteRole
- Actor上有两个属性跟复制相关,即Role跟RemoteRole,这个跟Ownership不是一回事,通过这两个属性可以知道
- 谁对这个Actor拥有Authority权限:通过判断Role是否等于ROLE_Authority
- 这个Actor是否需要进行复制,如果Role是ROLE_Authority,且RemoteRole是ROLE_SimulatedProxy或 ROLE_AutonomousProxy(说明就是DS),那么当前代码逻辑所对应的Engine Instance(目前只有DS有此能力)就需要将这个Actor复制给Remote的Connection
- 复制的模式是什么。Actor的复制不是每帧一次,而是根据AActor::NetUpdateFrequency来的,这就意味着在客户端上拿到的数据可能是不连续的,需要在本地进行补偿模拟,以获得流畅的体验,而这里主要有两种模拟方式,对应于不同的角色类型
- ROLE_SimulatedProxy,这是最标准的做法,具体而言,就是基于此前的velocity进行position的外插值。当客户端收到DS发送过来的Actor的更新数据,就会基于当前位置朝着DS下发的目标位置移动(不知道是直接设置位置,还是平滑移动?),到达目标位置后,就会基于DS下发的速度进行移动的模拟
- ROLE_AutonomousProxy,这种对应的是PlayerController操控的Actor的模拟,相对于上一种模拟,这种模拟的优势是我们知道后续的一些输入,因此在进行插值的时候会更为准确,模拟结果也更平滑
- 同样一个Actor在不同的Engine Instance上的角色可能是对调关系:
- Actor上有两个属性跟复制相关,即Role跟RemoteRole,这个跟Ownership不是一回事,通过这两个属性可以知道
-
Detailed Actor Replication Flow,角色属性同步工作流
- 角色属性同步从DS发起,入口为UNetDriver::ServerReplicateActors,在这个接口中,DS会搜集每个client相关的Actor列表,并根据每个client对应的connection上次更新的时间,完成变更数据的下发。整体的同步流程给出如下:
- 遍历每个要求主动同步的Actor: (AActor::SetReplicates( true ))
- 先判断Actor是否处于休眠状态 - DORM_Initial,是则跳过
- 再根据NetUpdateFrequency 判断是否需要同步,如果不需要则跳过
- 如果AActor::bOnlyRelevantToOwner为true,通过调用AActor::IsRelevancyOwnerFor来判断这个actor在对应owning connection的relevancy,当判定为relevant时,将之添加到connection上的owned relevant list中
- 这种情况下,这个Actor的同步消息只会发送给单个Connection
- 通过上述判定之后,就会调用AActor::PreReplication
- 在这个接口中,可以通过DOREPLIFETIME_ACTIVE_OVERRIDE来决定是否需要将某个属性同步给Connection
- 如果上述判定条件都通过了,就将Actor添加到Considered List中
- 遍历每个connection:
- 遍历Connection中通过上面判定的每个actor
- 判断是否处于休眠
- 如果目前还没有分配channel
- 再判断这个客户端是否已经加载了这个Actor所从属的Level
- 如果还没有加载,则跳过
- 再通过AActor::IsNetRelevantFor判断actor对于这个Connection是否是relevant
- 如果不相关,就跳过
- 再判断这个客户端是否已经加载了这个Actor所从属的Level
- 将通过上述判定规则的Actor添加到Connection的relevant list
- 根据优先级对Relevant List中的Actor进行排序
- 遍历排序后的Actor:
- 如果Connection还没有加载Actor所对应的Level,就先将这个Channel关闭,并且Continue
- 定时(每秒钟)调用AActor::IsNetRelevantFor来判断Actor与Connection是否Relevant
- 如果连续5秒钟,得到的结构都是不相关,就关闭此Channel
- 如果是Relevant,但是Channel没有open,就open一个
- 如果此时的Connection已经饱和了(saturated)
- 遍历剩下的其他的Actors
- 如果某个Actor对Connection的Relevant时间不高于1s,那么就在下次tick时强制update
- 否则,通过AActor::IsNetRelevantFor来判断是否需要在下次Tick是Update
- 如果某个Actor对Connection的Relevant时间不高于1s,那么就在下次tick时强制update
- 遍历剩下的其他的Actors
- 对于所有通过上述判断的Actor,就会通过UChannel::ReplicateActor完成属性同步,这个接口会完成Actor与所有Components的属性复制,具体逻辑给出如下
- 先判断这是否是Actor Channel打开之后的首次更新
- 如果是首次更新,先完成必要信息(initial location, rotation, etc)的序列化
- 再判断这个Connection是否拥有这个Actor
- 如果返回false(不拥有),并且这个Actor的角色是ROLE_AutonomousProxy,那么就将之角色降级为ROLE_SimulatedProxy
- 对角色上发生变化的属性数据进行复制
- 对每个Component中变化的属性进行复制
- 对每个删除的Components,发送定制的delete command
- 先判断这是否是Actor Channel打开之后的首次更新
- 遍历Connection中通过上面判定的每个actor
- 遍历每个要求主动同步的Actor: (AActor::SetReplicates( true ))
- 跟角色属性同步相关一些重要接口为:
- AActor::NetUpdateFrequency - 用来获取Actor的同步频率
- AActor::PreReplication - 在属性同步前调用
- AActor::bOnlyRelevantToOwner - 当Actor的属性同步消息只发给Owner时返回true
- AActor::IsRelevancyOwnerFor - 当bOnlyRelevantToOwner设置为true时,通过这个接口来获取relevancy
- AActor::IsNetRelevantFor - 当bOnlyRelevantToOwner设置为false时,通过这个接口来获取relevancy
- 角色属性同步从DS发起,入口为UNetDriver::ServerReplicateActors,在这个接口中,DS会搜集每个client相关的Actor列表,并根据每个client对应的connection上次更新的时间,完成变更数据的下发。整体的同步流程给出如下:
-
- 为了节省带宽,DS到客户端的同步是要做限制的,即只有客户端最相关的Actor才会触发同步,而不会将所有的Actor都同步下去,要同步给某个Client的Actor会放在Server中跟这个Client相关的Relevant Set中
- 某个Actor是否是Relevant的,是通过AActor::IsNetRelevantFor()判断的
- UE为每个Actor的同步优先级制定了一套规则,load-balancing,在带宽有限的情况下,优先同步重要性高的Actor
- 优先级通过NetPriority指定,这个数值越大,拥有的同步带宽就越高:An Actor with a priority of 2.0 will be updated exactly twice as frequently as an Actor with priority 1.0,常用Actor的优先级数值:
- Actor = 1.0
- Matinee = 2.7
- Pawn = 3.0
- PlayerController = 3.0
- 通过AActor::GetNetPriority()计算,里面会考虑玩家跟Actor的相对位置。
- 优先级通过NetPriority指定,这个数值越大,拥有的同步带宽就越高:An Actor with a priority of 2.0 will be updated exactly twice as frequently as an Actor with priority 1.0,常用Actor的优先级数值:
-
Actor更新有两种方式
-
属性同步:每当检测到变化,就会自动完成(比如角色血量就适合走这种更新模式),为了实现自动更新,这里就需要定时检测数据是否变化,而这种检测是有消耗的
- 需要同步的属性(注意,这里都指的是Actor中的属性)要添加Replicated修饰符,且需要在GetLifetimeReplicatedProps接口中添加说明
DOREPLIFETIME(ATestPlayerCharacter, Health); DOREPLIFETIME_CONDITION(ATestPlayerCharacter, Health, COND_OwnerOnly);
-
UPROPERTY( replicated )
-
属性同步都是DS发起的,客户端无权触发
-
这个同步是可靠的,即客户端的数据最终是确定可以跟服务器的数据保持一致的,但是需要注意的是,这里不会确保一个属性的中间变化也会被同步给客户端,比如某个数值快速从100到200最终到300,客户端并不确保一定会收到200
-
每个Actor的属性同步频率可以通过修改NetUpdateFrequency变量(每秒同步次数)进行调整,对于不需要频繁更新的Actor可以相应降低这个数值
- 一些重要的Actor比如PlayerController可以设置为10
- 一些相对不重要的Actor如AI可以设置为5
- 一些对玩法不重要的如背景Actor可以设置为2
-
自适应频率属性同步策略
- 可以根据一些上下文来动态调整更新频率
- 当两秒之内没有发生过重大的属性变更,就会降低同步频率,与之相应的,当发生了一次重大变更后,倾向于认为接下来还会有重大变更,因此会调高同步频率
- 默认关闭,通过net.UseAdaptiveNetUpdateFrequency打开
- 频率变化区间为:MinNetUpdateFrequency - NetUpdateFrequency
- 可以根据一些上下文来动态调整更新频率
-
RPC更新:被调用的时候触发(比如游戏逻辑需要在某个位置触发一次爆炸,这个爆炸就可以通过RPC消息从DS发送给客户端)
-
本地调用,远程执行
- UFUNCTION( Server ):客户端侧发起的RPC(在DS上执行)
- UFUNCTION( Client ):DS侧发起的RPC(在客户端上执行)
- UFUNCTION( NetMulticast ):
- DS发起,DS+客户端同时执行
- 客户端发起,只有客户端本地执行,其他客户端或DS都不执行
-
默认unreliable,可以通过添加Reliable修饰符来调整(不过这个机制目测会存在较高的损耗,因此建议只用于非常重要的函数)
- UFUNCTION( Client, Reliable )
-
支持添加Validation验证,验证通过才会执行,如果验证不通过,那么发起这个RPC的instance会断开,比如现在每个执行在Server上的RPC都要求启用Validation,如果某客户端发起的RPC没有验证通过,就会断开客户端跟DS的连接(惩罚机制太严苛了吧?)
- UFUNCTION( Server, WithValidation )
- void SomeRPCFunction( int32 AddHealth );
- bool SomeRPCFunction_Validate( int32 AddHealth )
-
限制条件
- 没有返回值
- 只能写在Actor中
- 且Actor必须要是Replicated的
- 拥有条件
- 在客户端上执行的RPC,只有当client真正own这个Actor,这个RPC才会被执行(即需要判断是否拥有)
- 在客户端上发起的RPC,同样需要判断这个client是否拥有这个Actor,否则无法发起
-
Multicast RPC不需要考虑上述限制
-
Actors and their Owning Connections
- 每个Connection(网络连接?)都对应于一个PlayerController
- 要想知道Actor对应的Connection,只需要找到这个Actor最外层的Owner,如果这个Owner就是PlayerController,那么就知道这个Actor跟PlayerController从属于同一个Connection
- 一个示例,Pawn在被PlayerController Possess期间,具有与PlayerController相同的Connection,当UNPossess之后,两者的Connection可能就不一样了
- Component的Connection则是通过找到其对应的Owning Actor计算的
- Connection会用在如下的地方
- RPC需要知道当DS发起一个RPC之后,究竟哪个Client会需要执行这个RPC,而这个是通过Connection完成的
- Actor的Replication以及Connection的Relevancy
- 通过Connection来实现Actor的更新
- 每个Actor都有一个变量:bOnlyRelevantToOwner 。如果这个变量设置为true,那么就只有拥有这个Actor的Connection会收到这个Actor属性同步的消息
- 所有的PlayerController都默认会打开这个选项,这就是为什么每个客户端只会收到PlayerController的更新消息
- 这个机制是出于同步效率以及防作弊考虑
- Actor属性复制条件
- 比如使用COND_OnlyOwner的话,就只有Actor的Owner会收到属性更新的消息(通过这种方式可以实现DS上Actor的数据只同步给单一的客户端)
-
-
除了属性数据的自动同步之外,UE还提供了另外一种Replication机制,叫做RepNotify。这个机制是通过在所有instance(客户端与服务器)收到某个属性更新的消息之后,自动触发的一个函数调用来实现的。如果我们希望在某个属性发生变化后触发对应的后续处理逻辑,就可以考虑通过这个方法来实现。
在蓝图中,我们只需要在面板中勾选Replication之后的行为模式为OnRepNotify即可:
在C++中,则需要通过ReplicatedUsing修饰符完成,不过需要注意的是,OnRep函数必须是UFunction。
/* Header file inside of the Classes declaration */
// Create RepNotify Health variable
UPROPERTY(ReplicatedUsing=OnRep_Health)
float Health;
// Create OnRep Function | UFUNCTION() Macro is important! | Doesn't need to be virtual though
UFUNCTION()
virtual void OnRep_Health();
----------------
/* CPP file of the Class */
void ATestCharacter::OnRep_Health() {
if(Health < 0.0f)
PlayDeathAnimation();
}
OwnerShip
PlayerController是Client或者Listen Server所拥有的;Spawned Actor通常被Server所拥有的。拥有权对游戏逻辑有着一些约束,比如Client不能调用Server所拥有的Actor的RPC(在Server上执行)。所以如果客户端想要调用服务器上的某段逻辑,需要通过Client拥有的Actor(比如PlayerController)来触发Server的操作(如调用PlayerController上的Server_Operation接口,通过这个接口调用其他Server所拥有的的Actor的其他接口)。
前面说过,PlayerController是玩家所拥有的第一个Class,且每个Connection都会创建一个PlayerController,如果我们想要判定某个Actor是否被这个Connection所拥有,只需要不断查找这个Actor的Outer,如果查到最后发现Outer等于PlayerController,就说明这个Actor是被这个Connection所拥有的。
Owner还有如下一些应用:
- 在Client上执行的RPC函数,只会在Client拥有这个Actor时才会触发
- Actor的Replication以及Relevancy计算(下面会介绍)
- Actor的属性复制条件判定逻辑
Actor Relevancy
什么是Relevancy?当游戏世界变得很大时,玩家A通常并不需要感知到遥远时空中发生的事情,因此为了节省网络带宽,会限制服务器同步给A的数据的范围,只将A需要关心的Actor放入到Relevant Set中。
前面说到过,Actor是否Relevant是通过AActor::IsNetRelevantFor()接口判定的,判定条件前面也有提到,这里就不做赘述了,值得注意的是,Pawn 跟PlayerController可以对这个接口进行定制,从而根据需要调整判定逻辑。
同步优先级Priorities
既然Actor的同步需要考虑相关性,那么这里面自然有同步优先级的考虑。这一部分,前面也介绍过,UE通过NetPriority指定Actor的优先级。
Traveling in Multiplayer
在多人游戏中的体验有两种,一种是无缝的穿梭体验,一种是有缝的穿梭体验。
有缝穿梭指的是,角色在地图中行走的时候,当遇到需要加载新地图时,会触发角色跟服务器的断开,当完成新地图加载后再重新连接上的一种体验,无缝穿梭则是客户端无感的地图加载模式。
驱动Traveling有三个主要的函数:
- UEngine::Browse,这个相当于本地加载一个新地图,通常会导致有缝的体验:即客户端会跟当前服务器断开,而服务器如果调用这个接口也会触发所有客户端的断开。
- UWorld::ServerTravel,服务器调用。服务器调用时,会通过APlayerController::ClientTravel通知所有连接上来的客户端进行相同的地图加载,在此过程中,不会发生连接的断开(只是不知道,如果部分玩家因为距离过远不需要加载新地图要怎么办?)
- APlayerController::ClientTravel,如果在客户端上触发,那么客户端就会连接到一个新的服务器;如果从一个服务器上调用,那就会触发对应客户端加载新的地图。
无缝Travel需要用到Transition Map(loading界面,看来跟我理解的无缝还有比较大的差距),这个地图可以通过UGameMapsSettings::TransitionMap配置,默认情况下是空的,如果不做配置,就会自动创建一个空的关卡。loading界面的存在是为了隐蔽背后的旧关卡卸载与新关卡加载的逻辑(多用于并不相连的关卡加卸载)。
在设置好Loading界面之后,通过设定AGameMode::bUseSeamlessTravel为true就可以触发Seamless Travel了。
在Seamless Travel中,需要考虑将一些常驻的Actor从旧关卡中带到新关卡,下面的Actor默认就是常驻的,会自动完成这个过程:
- GameMode Actor (Server only)
- 通过'AGameMode::GetSeamlessTravelActorList'添加的Actors
- 具有有效的PlayerState (Server only)的Controllers
- 所有的PlayerControllers (Server only)
- 所有的Local PlayerControllers (Server and Client)
- 所有在local PlayerControllers上通过'APlayerController::GetSeamlessTravelActorList' 添加的Actors
Seamless Travel的流程可以描述如下:
- 标记对于Loading地图而言属于常驻的Actors
- 打开Loading地图
- 标记对于目标地图而言属于常驻的Actors
- 打开目标地图
Online Subsystem
Online Subsystem以及相应的接口会提供一套跨平台的功能逻辑,通过这套逻辑,开发者可以无需过多关注平台底层细节,直接通过上层接口来得到在多个平台上一致的表现。
默认情况下,我们可以使用SubsystemNULL来拿到LAN Sessions(Session可以理解成一个Game Instance,即在服务器上运行的一个游戏进程,游戏大厅中看到的每个游戏都可以看成是一个Session。Session具有Advertised跟Private两种,前者可以供人们自由加入,后者则只能通过邀请才可加入),或者直接通过ip连接到服务器。
基础的Online Subsystem Module指定了各个平台的对应模块是如何定义与注册的,对各个平台的模块的调用都要通过这个模块完成,这个模块在加载的时候,会通过Engine.ini配置文件加载对应平台的模块,加载成功之后可以通过下面的接口拿到对应的Subsystem:
[OnlineSubsystem]
DefaultPlatformService = <Default Platform Identifier>
--------------------
static IOnlineSubsystem* Get(const FName& SubsystemName = NAME_None);
Online Subsystem中的很多接口都是通过异步的委托来完成的,在使用的时候要注意时序关系。使用前先add,不使用的时候记得clear。
MatchMaking是将Players跟Sessions关联起来的一个过程,每个Session的生命周期按照时间顺序包含如下的一些阶段:
• 根据配置创建Session
• 等待玩家加入
• 为准备加入的玩家进行注册
• 启动Session
• 开始游戏
• 结束Session
• 通过如下的两种方式完成玩家的反注册:
- 如果只是希望更改游戏类型的话,只需要更新Session并等待玩家加入即可
- 销毁Session
Session的访问接口IOnlineSession提供了平台相关的一系列方法,通过这些方法可以完成玩家的加入与注册等逻辑,其中就包含了Session的管理逻辑,这个接口是通过Online Subsystem创建的(Owner也是Online Subsystem),也就是说,这个接口只在Server上存在,且这个接口是单例的。
虽然Session相关的操作都是通过Session接口完成的,但是实际上游戏逻辑并不直接与之交互,而是通过GameSession(AGameSession)完成的,GameSession可以看成是对Session接口的封装,这个Actor是GameMode负责创建的(也是GameMode所拥有),因此也是只存在于Server上。
每个游戏虽然可以包含多个GameSession,但是同一时刻只能有一个GameSession起作用(GameMode也是如此)。
Session的基本配置可以在FOnlineSessionSettingsclass中找到,包含了可以连接上来的角色数目,Session类型(Advertised还是Private)等。
Session的节点或者函数调用都是异步完成的,会返回一个执行结果告知对应的操作是否成功,原文中给了具体的案例指示了如何对Session进行管理,包括创建、更新、销毁、加入、查找等逻辑,这里不做赘述,有兴趣可以前往原文学习。
在Session的使用上,可以通过IonlineSession::Startmatchmaking()来进行游戏匹配,完成后会触发OnMatchmakingComplete,不过需要注意的是,不是所有平台都支持这个操作,需要在使用前进行确认,匹配过程中也可以通过IOnlineSession::CancelMatchmaking()接口取消匹配,完成后会触发OnCancelMatchmakingCompletedelegate。通过IOnlineSession:FindFriendSession()可以实现跟随某个好友进入到对应的Session的操作,与之匹配的委托为OnFindFriendSessionComplete,也可以通过IOnlineSession::SendSessionInviteToFriend()一级IOnlineSession::SendSessionInviteToFriends()等接口邀请好友加入。
MultiPlayer Game
可以通过如下图所示的配置启动多人游戏:
通过Advanced Settings可以进入高级设置界面:
这里的相关参数说明给出如下:
可以指定参与游戏的玩家数目,Server的一些参数,是否运行DS,是否自动连接到Server等。
当我们勾选了Single Process的时候,就会在同一个Engine Instance中创建多个玩家角色,这种模式不需要为每个角色创建一个Instance,启动速度会更快一些,不过在程序上由于耦合度较高,会存在一些潜在的问题,与之相关的配置如下图所示:
这些配置的含义给出如下:
Editor MultiPlayer Mode指的是PIE下的NetMode,可以是Offline,或者LS或当成Client。另两个参数名字比较直观,不做解释。
当我们需要将某个Engine Instance当成DS来执行,可以勾选下图所示的复选框:
如果不勾选的话,第一个Client会被当成LS来执行,勾选之后,所有玩家操控的Engine Instance就都是Client了。
在代码中要想启动Server或者连接到Server,可以通过如下接口实现:
UGameplayStatics::OpenLevel(GetWorld(), “LevelName”, true, “listen”);
//-----------
// Assuming you are not already in the PlayerController (if you are, just call ClientTravel directly)
APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
PlayerController->ClientTravel(“IPADDRESS”, ETravelType::TRAVEL_Absolute);
除此之外,我们还可以通过命令行来启动.uproject实现不同的Mode:
需要注意的是,如果我们在启动DS的时候,不添加log标志,就不会弹出任何窗口。
连接过程
当某个客户端第一次连接到服务器上时,会触发一些逻辑:
-
客户端会向服务器发送连接请求
-
服务器收到请求后,如果不拒绝的话,就会回包,包中会带有连接所需要的一些信息。
下面给出具体的步骤说明: -
客户端发送连接请求
-
服务器同意后,会下发当前的map信息,并等待客户端完成map的加载
-
客户端加载完成后,服务器会在本地调用AgameMode::PreLogin,这个接口会通知GameMode,GameMode会在这个过程中决定是否要拒绝此次连接
-
GameMode判定通过后,服务器会调用AgameMode::Login,这个接口会创建一个PlayerController,之后复制到客户端上,这个过程完成后,就会用新的PlayerController取代此前连接过程中临时创建的PlayerController(占位使用),需要注意的是,APlayerController::BeginPlay接口会在这个时候触发,并且此时调用RPC是不安全的,需要等待AGameMode::PostLogin调用之后才可以。
-
服务器调用AGameMode::PostLogin,此时Server可以在对应的PlayerController上调用RPC。
网友评论