【UE】'Unreal Engine 4' Network Co

2022-08-22  本文已影响0人  离原春草

最近在网上发现了一篇关于UE网络入门的文章(原文在文末给出),对UE的网络做了较为系统的陈述,这里将学习到的一些要点整理出来,方便后续回顾并不断加深理解。

原文的概念都是基于UE4.14给出的,跟最新的代码会有一些差异,不过不影响对UE网络方案的整体理解,后续会随着工作与学习的深入,也会对这里面过时的内容进行更新,使之保持在一个较为updated的状态。

此外,原文在介绍每个概念的时候,会辅以具体的案例(代码片段、蓝图截图等)加以说明,因为相对较为简单,就没有直接搬运过来,如果理解上存在困难可以移步原文进行对照阅读。

1. 概述

UE使用的是一个标准的C/S架构,即服务器上的数据具有最高解释权,客户端的操作(比如移动,或者发送消息给其他客户端)需要先发送给服务器,经过服务器校验之后才会同步给其他客户端。

联网游戏开发的一个基本要求就是,凡是跟玩法挂钩的逻辑,都要走服务器校验,不要相信客户端,否则就会导致作弊,从而影响游戏的平衡,严重的话甚至会威胁到产品的生命力。

2. 网络框架

根据Actor的出现位置(C/S)不同,我们可以将Actor(Object)分成如下的四类,而后面的陈述则是基于这种划分来展开的:

如上图所示:基于上述划分,我们可以将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提供了一系列的虚函数供用户重写:

同时,GameMode还提供了一系列自定义的事件供用户监听并做出响应:

此外,GameMode本身也带有一系列的成员变量用于提供一些自定义的开关或者控制逻辑:

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中可用的一些数据有:

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);'实现,但是这两个调用在客户端跟服务器上有不同表现:

为什么我们需要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就只能存在于本地客户端上。

除了属性数据的自动同步之外,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还有如下一些应用:

Actor Relevancy
什么是Relevancy?当游戏世界变得很大时,玩家A通常并不需要感知到遥远时空中发生的事情,因此为了节省网络带宽,会限制服务器同步给A的数据的范围,只将A需要关心的Actor放入到Relevant Set中。

前面说到过,Actor是否Relevant是通过AActor::IsNetRelevantFor()接口判定的,判定条件前面也有提到,这里就不做赘述了,值得注意的是,Pawn 跟PlayerController可以对这个接口进行定制,从而根据需要调整判定逻辑。

同步优先级Priorities
既然Actor的同步需要考虑相关性,那么这里面自然有同步优先级的考虑。这一部分,前面也介绍过,UE通过NetPriority指定Actor的优先级。

Traveling in Multiplayer
在多人游戏中的体验有两种,一种是无缝的穿梭体验,一种是有缝的穿梭体验。

有缝穿梭指的是,角色在地图中行走的时候,当遇到需要加载新地图时,会触发角色跟服务器的断开,当完成新地图加载后再重新连接上的一种体验,无缝穿梭则是客户端无感的地图加载模式。

驱动Traveling有三个主要的函数:

  1. UEngine::Browse,这个相当于本地加载一个新地图,通常会导致有缝的体验:即客户端会跟当前服务器断开,而服务器如果调用这个接口也会触发所有客户端的断开。
  2. UWorld::ServerTravel,服务器调用。服务器调用时,会通过APlayerController::ClientTravel通知所有连接上来的客户端进行相同的地图加载,在此过程中,不会发生连接的断开(只是不知道,如果部分玩家因为距离过远不需要加载新地图要怎么办?)
  3. APlayerController::ClientTravel,如果在客户端上触发,那么客户端就会连接到一个新的服务器;如果从一个服务器上调用,那就会触发对应客户端加载新的地图。

无缝Travel需要用到Transition Map(loading界面,看来跟我理解的无缝还有比较大的差距),这个地图可以通过UGameMapsSettings::TransitionMap配置,默认情况下是空的,如果不做配置,就会自动创建一个空的关卡。loading界面的存在是为了隐蔽背后的旧关卡卸载与新关卡加载的逻辑(多用于并不相连的关卡加卸载)。

在设置好Loading界面之后,通过设定AGameMode::bUseSeamlessTravel为true就可以触发Seamless Travel了。

在Seamless Travel中,需要考虑将一些常驻的Actor从旧关卡中带到新关卡,下面的Actor默认就是常驻的,会自动完成这个过程:

Seamless Travel的流程可以描述如下:

  1. 标记对于Loading地图而言属于常驻的Actors
  2. 打开Loading地图
  3. 标记对于目标地图而言属于常驻的Actors
  4. 打开目标地图

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的访问接口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标志,就不会弹出任何窗口。

连接过程
当某个客户端第一次连接到服务器上时,会触发一些逻辑:

  1. 客户端会向服务器发送连接请求

  2. 服务器收到请求后,如果不拒绝的话,就会回包,包中会带有连接所需要的一些信息。
    下面给出具体的步骤说明:

  3. 客户端发送连接请求

  4. 服务器同意后,会下发当前的map信息,并等待客户端完成map的加载

  5. 客户端加载完成后,服务器会在本地调用AgameMode::PreLogin,这个接口会通知GameMode,GameMode会在这个过程中决定是否要拒绝此次连接

  6. GameMode判定通过后,服务器会调用AgameMode::Login,这个接口会创建一个PlayerController,之后复制到客户端上,这个过程完成后,就会用新的PlayerController取代此前连接过程中临时创建的PlayerController(占位使用),需要注意的是,APlayerController::BeginPlay接口会在这个时候触发,并且此时调用RPC是不安全的,需要等待AGameMode::PostLogin调用之后才可以。

  7. 服务器调用AGameMode::PostLogin,此时Server可以在对应的PlayerController上调用RPC。

参考

[1]. 'Unreal Engine 4' Network Compendium
[2]. UE4-多人游戏框架理解

上一篇 下一篇

猜你喜欢

热点阅读