ue-multiplayer
UE5 Multiplayer
LAN
在同一个局域网(LAN)中,可以创建一个游戏会话,并让多个玩家加入。
这里在PlayerController
中创建函数,然后在蓝图调用。可以作为listen
server开启服务器,也可以作为client通过ip地址加入别人的listen server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18void AMyPlayerController::OpenLobby()
{
UWorld* World = GetWorld();
if (World)
{
World->ServerTravel("/Game/ThirdPerson/Maps/Lobby?listen");
}
}
void AMyPlayerController::CallOpenLevel(const FString& Address)
{
UGameplayStatics::OpenLevel(this, *Address);
}
void AMyPlayerController::CallClientLevel(const FString& Address)
{
ClientTravel(Address, TRAVEL_Absolute);
}
Online Subsystem
通过LAN连接,需要保证每个player都在同一个局域网中。然而我们希望可以实现跨地区的连接,这表明不同的玩家可能处于不同的局域网中,无法采用直接输入局部IP的方式来加入某个listen server。
当然我们有全局ip,但是玩家还是需要手动输入ip地址,这显然不方便。我们希望能够在游戏中直接搜寻玩家的ID,然后加入他们的游戏。
一种方式是对你自己的游戏建立专门的服务器,该服务器要实现能够维护一个IP列表的功能来表明所有的玩家。然而这种方式成本较高,我们希望还是通过listen server的方式来实现。
另一种方式是采用所谓的在线服务(Online Services),例如steam和Xbox。这种在线服务不仅能够抽象IP层面,还有其它的功能(后续会提到,比如成就系统)。
当然,所谓的在线服务不直接决定使用 Listen Server 还是 Dedicated Server,它的核心目标是 帮助玩家发现和加入游戏会话,而会话的实际运行模式(Listen/Dedicated)由开发者配置。
对于不同的service,虽然它们提供的功能大致相同,但是它们在实现上有所不同。例如创建会话(Session),Steam有自己的一套实现,Xbox也有自己的一套实现。
UE5中,我们使用Online Subsystem来抽象这些不同的service。(这就好比用RHI来抽象不同的图形API?)
Online Subsystem Steam
这里采用Steam作为Online Service。在编辑器里加入插件,然后在游戏模块中加入OSS和Steam的模块,然后在DefaultEngine.ini中配置相关项。
目前的话,我们想要通过Steam来创建会话。
GPT: 在多人游戏开发中,“会话”(Session)通常指的是一次游戏匹配或房间实例,它包含了游戏设置、玩家列表、连接信息等。也就是说,当玩家创建一个游戏并允许其他玩家加入时,这个创建出来的组就叫做一个会话。会话管理确保所有参与者能够在同一环境下进行游戏互动,并且负责处理连接、匹配、邀请以及会话广告等功能。
我们首先要获取我们的OSS: 1
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();
回顾一下OSS的定义:OnlineSubsystem - Series of interfaces to support communicating with various web/platform layer services,也就是说,OSS内部包含了一系列的接口,用于支持与各种web/平台层服务的通信。
然后有趣的是,OSS本身也是一个接口(它的前缀是I)。这里通过Get()获取的是一个具体的实现,例如SteamOnlineSubsystem。
可以通过阅览OnlineSubsystemSteam的源码来确认。FOnlineSubsystemSteam继承FOnlineSubsystemImpl,而FOnlineSubsystemImpl则继承了IOnlineSubsystem。
由于我们需要OSS的创建会话相关功能,故提取该接口: 1
2
3
4
5
6
7
8
9
10
11
12
13if (OnlineSubsystem)
{
OnlineSessionInterface = OnlineSubsystem->GetSessionInterface(); //这个是成员变量
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString::Printf(TEXT("Online Subsystem %s"), *(OnlineSubsystem->GetSubsystemName().ToString()))
);
}
}
然后就可以创建会话了。这里为了确认创建了会话,我们使用一个委托FOnCreateSessionCompleteDelegate
,并将其绑定到一个自己的函数void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
。
所谓delegate,我目前的理解就是在某些情况下进行回调。在ue中有很多类型的delegate,这里的
FOnCreateSessionCompleteDelegate
也是一类,它在创建会话后回调,它对应的回调函数要求有特定的函数签名(直接查看定义就能获取)。我们将其绑定到函数void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
上。绑定的过程可以直接和创建delegate一起:OnCreateSessionCompleteDelegate =(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete))
然后可以正式创建会话: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40void AMenuSystemCharacter::CreateSession()
{
if (!OnlineSessionInterface.IsValid())
{
// Session Interface is null, just return
return;
}
// check whether we have created a session before. If it exists, destroy it!
const FNamedOnlineSession* ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession);
if (ExistingSession)
{
OnlineSessionInterface->RemoveNamedSession(NAME_GameSession);
}
// Add our delegate to OnlineSessionInterface's delegate list, otherwise we'll not have our callback
OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegate);
const TSharedPtr<FOnlineSessionSettings> OnlineSessionSettings = MakeShareable(new FOnlineSessionSettings());
OnlineSessionSettings->bIsLANMatch = false; // We don't use LAN, we use the internet.
OnlineSessionSettings->bAllowJoinInProgress = true; // We allow other clients to join when the session is alive.
OnlineSessionSettings->NumPublicConnections = 4; // Public Connections don't need password?
OnlineSessionSettings->bAllowJoinViaPresence = true; // 在线方式???不太懂
OnlineSessionSettings->bShouldAdvertise = true; // 公开在线会话
OnlineSessionSettings->bUsesPresence = true; // 应该是搭配 bAllowJoinViaPresence。得先有Presence,才能via Presence
OnlineSessionSettings->bUseLobbiesIfAvailable = true; // 这个应该是steam必备的,不然无法创建会话
const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *OnlineSessionSettings);
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString("Try to Create Session!")
);
}
}OnlineSessionInterface
被智能指针包裹着,因此需要用 isValid
来判断)。
然后判断是否已经创建了会话。
然后将我们创建的delegate添加到OnlineSessionInterface的delegate list中。
然后设置会话的属性。这里稍微提一下,我也不是很确定,就是Steam
要求匹配、好友邀请以及会话广告都是通过 Lobby 系统来实现的,因此一定要将
OnlineSessionSettings->bUseLobbiesIfAvailable
设置为
true。
注意:事实上这里的Presence代表的是Steam的Region。我们只支持在同一个区域内进行会话的查找,在Steam设置Region:
![]()
创建了会话之后,我们需要实现查找会话。原理是一样的,只不过需要设置不同的delegate和settings。
1 | void AMenuSystemCharacter::JoinSession() |
这里注意我们需要传入一个 FOnlineSessionSearch
的引用,在寻找会话时,会往其内部其添加寻找结果
SessionSearch->SearchResults
,这是一个
TArray<FOnlineSessionSearchResult>
,可以通过遍历查找我们要加入的Session。
为了查找我们要的Session,在创建会话时可以指定一个键值对:
1
2// key value pair to filter sessions
OnlineSessionSettings->Set(FName("MatchType"), FString("FuckNUAA"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
查找到Session后,我们就可以加入会话了。在加入会话对应的delegate实现加入会话的逻辑,例如通过获取ip来进入会话的level:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25void AMenuSystemCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
if (!OnlineSessionInterface.IsValid())
{
return;
}
FString Address;
if (OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address))
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Yellow,
FString::Printf(TEXT("Connect string: %s"), *Address)
);
}
APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
if (PlayerController)
{
PlayerController->ClientTravel(Address, TRAVEL_Absolute);
}
}
}
Plugin
以上大致就是我们通过OSS来创建会话、查找会话、加入会话的流程。这流程和游戏项目本身无关,因此考虑将其封装成Plugin。
所谓的Plugin(插件),其实就是一系列独立的代码(Code)和资源(Resource),它们可以实现游戏运行时的一些功能,也可以实现编辑器的一些功能。可以直接在引擎里创建Plugin:

Module
讲到Plugin,顺便讲讲Module。Module是UE中的一种组织代码的方式,它将代码组织成一个独立的模块,可以被其他模块调用。Module只有代码没有资源,每个Module只实现一个单一的目标,且只包含一个
.Build.cs
文件(用于构建)。我们的游戏本身也是一个Module,可以在
.Build.cs
里调用其它Module。
Plugin本身其实就是由一个或者多个Module组成。
Module(Plugin)之间具有依赖关系,但这种依赖关系并不是没有限制的。事实上有层级限制:

显然Engine层面的Module或Plugin不能依赖Game层面的Module。但我们Game层面的Module可以依赖Engine层面的Module(Plugin)。
在游戏项目的 .uproject
文件中,我们可以看到我们当前依赖的Plugin,这里包括之前指定的OSSSteam:
1 | "Plugins": [ |
对于Plugin,它对应的文件后缀为
.uplugin
。我们在里面添加我们需要的Plugin:
1 | "Plugins": [ |
在对应的 .Build.cs
文件中,需要添加Module:
1
2
3
4
5
6
7
8
9PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
"OnlineSubsystem",
"OnlineSubsystemSteam"
// ... add other public dependencies that you statically link with here ...
}
);
GPT:
.uplugin
文件负责在插件层面声明依赖关系,确保必要的插件被加载;而.Build.cs
文件则在编译层面指定模块依赖,确保编译过程顺利进行。两者协同工作,确保插件在运行时和编译时都能正确地识别和使用其依赖项。我也不是很能理解。
Replicate
接下来就正式进入游戏内容部分。
我们定义了一个 AWeapon
类,用于实现武器的逻辑。武器具有以下状态: 1
2
3
4
5
6
7
8
9UENUM(BlueprintType)
enum class EWeaponState: uint8
{
EWS_Initial UMETA(DisplayName = "Initial State"),
EWS_Equipped UMETA(DisplayName = "Equipped"),
EWS_Dropped UMETA(DisplayName = "Dropped"),
EWS_MAX UMETA(DisplayName = "DefaultMAX")
};
当武器放置在地上的时候,我们希望角色能够可以拾取它。因此设置一个trigger sphere,当角色进入sphere时,显示拾取武器的Widget。
但是在多人游戏中,这种逻辑一般只在服务器上运行。要知道服务器上运行的游戏版本是权威的,同时只在服务器上运行重要的逻辑,具有单例模式的优势,处理起来也比较简单。
服务器处理完一定逻辑后,需要将一些变量同步给客户端,例如在角色类中,我们设置了
OverlapWeapon
变量。在服务器端可以使用SphereComponent自带的Delegate来触发:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void AWeapon::OnAreaSphereBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
AFuckerCharacter* Fucker = Cast<AFuckerCharacter>(OtherActor);
if (Fucker)
{
Fucker->SetOverlapWeapon(this);
}
}
void AWeapon::OnAreaSphereEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
AFuckerCharacter* Fucker = Cast<AFuckerCharacter>(OtherActor);
if (Fucker)
{
Fucker->SetOverlapWeapon(nullptr);
}
}
以上函数只在服务器端运行。因此,当client的玩家进入sphere时,首先设置OverlapWeapon的是服务器的游戏实例上的角色。如果要设置同步,需要设置以下几点:
1
2
3
4
5
6// 在角色类中
UPROPERTY(ReplicatedUsing=OnRep_OverlapWeapon)
TObjectPtr<AWeapon> OverlapWeapon;
UFUNCTION()
void OnRep_OverlapWeapon(AWeapon* LastOverlapWeapon);OverlapWeapon
设置为Replicated,并设置 OnRep_OverlapWeapon
函数,该函数在每次 OverlapWeapon
变化时被调用,同时可以传入
OverlapWeapon 的旧值,这有利于我们实现逻辑。
然后需要将该变量进行注册: 1
2
3
4
5
6
7void AFuckerCharacter::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Only replicate OverlapWeapon to the Fucker that owns the replicated OverlapWeapon
DOREPLIFETIME_CONDITION(AFuckerCharacter, OverlapWeapon, COND_OwnerOnly);
}
这里 DOREPLIFETIME_CONDITION
是UE中用于设置变量同步的宏,COND_OwnerOnly
表示只有拥有该变化的变量的角色才会同步该变量。
可以在 OnRep_OverlapWeapon
函数中实现Overlap武器的逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13// Only Called on client
void AFuckerCharacter::OnRep_OverlapWeapon(AWeapon* LastOverlapWeapon)
{
// We now handle the OverlapWeapon on the client, but how about the server?
if (LastOverlapWeapon)
{
LastOverlapWeapon->ShowPickupWidget(false);
}
if (OverlapWeapon)
{
OverlapWeapon->ShowPickupWidget(true);
}
}
这样在Client端,当玩家进入sphere时,就会显示Widget。但是对于Server端,这个函数是不会触发的。
为了在Server端也触发这个逻辑,我们可以在修改OverlapWeapon时,特别考虑Server端的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// Only Called on server
void AFuckerCharacter::SetOverlapWeapon(AWeapon* Weapon)
{
// This will replicate OverlapWeapon to the client
// Except a situation: the server's controlled Fucker
// So we'll handle that
if (OverlapWeapon)
{
// We should check this Condition. Otherwise when the client's pawn on the server
// end overlap the weapon that the server's pawn is overlapping, the server will
// not display the widget as well.
if (IsLocallyControlled())
{
OverlapWeapon->ShowPickupWidget(false);
}
}
OverlapWeapon = Weapon;
if (OverlapWeapon)
{
if (IsLocallyControlled())
{
OverlapWeapon->ShowPickupWidget(true);
}
}
}IsLocallyControlled()
函数用于判断是否是本地控制的角色。由于该函数只在服务器端运行,因此这相当于判断该角色是否是服务器控制的角色。
>
第一次接触Replicate,我觉得有点复杂。我认为要将Server和Client分开,在OnRep系列函数实现Client端的逻辑,而在发生Replicate的地方特别执行Server端的逻辑。注意一定要判断
IsLocallyControlled()
,否则会导致逻辑混乱。(可以看我上面的注释,不判断的话会有bug)。