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
18
void 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
13
if (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
40
void 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!")
);
}
}
稍微理一下我写的方法。首先我们判断通过OSS获取的Session接口是否有效(这里的OnlineSessionInterface 被智能指针包裹着,因此需要用 isValid 来判断)。

然后判断是否已经创建了会话。

然后将我们创建的delegate添加到OnlineSessionInterface的delegate list中。

然后设置会话的属性。这里稍微提一下,我也不是很确定,就是Steam 要求匹配、好友邀请以及会话广告都是通过 Lobby 系统来实现的,因此一定要将 OnlineSessionSettings->bUseLobbiesIfAvailable 设置为 true。

注意:事实上这里的Presence代表的是Steam的Region。我们只支持在同一个区域内进行会话的查找,在Steam设置Region:

创建了会话之后,我们需要实现查找会话。原理是一样的,只不过需要设置不同的delegate和settings。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
void AMenuSystemCharacter::JoinSession()
{
if (!OnlineSessionInterface.IsValid())
{
// Session Interface is null, just return
return;
}
OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);

SessionSearch = MakeShareable(new FOnlineSessionSearch());
SessionSearch->bIsLanQuery = false;
SessionSearch->MaxSearchResults = 10000;
// We use Presence to create session, so we should find sessions which are created with Presence as well.
SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef());
}

void AMenuSystemCharacter::OnFindSessionsComplete(bool bWasSuccessful)
{
if (!OnlineSessionInterface.IsValid())
{
return;
}
if (!bWasSuccessful)
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString("Failed to find Sessions!")
);
}
}
else
{
for (auto OnlineSessionSearchResult : SessionSearch->SearchResults)
{
FString Id = OnlineSessionSearchResult.GetSessionIdStr();
FString Name = OnlineSessionSearchResult.Session.OwningUserName;
FString MatchType;
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Cyan,
FString::Printf(TEXT("Id: %s, User: %s"), *Id, *Name)
);
}
if (OnlineSessionSearchResult.Session.SessionSettings.Get(FName("MatchType"), MatchType))
{
if (MatchType == FString("FuckNUAA"))
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Cyan,
FString::Printf(TEXT("Joining Match Type: %s"), *MatchType)
);
}
OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);

const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, OnlineSessionSearchResult);
}
}
}
}
}

这里注意我们需要传入一个 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
25
void 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
2
3
4
5
6
7
8
9
10
11
12
13
"Plugins": [
{
"Name": "ModelingToolsEditorMode",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true
}
]

对于Plugin,它对应的文件后缀为 .uplugin。我们在里面添加我们需要的Plugin:

1
2
3
4
5
6
7
8
9
10
"Plugins": [
{
"Name": "OnlineSubsystem",
"Enabled": true
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true
}
]

在对应的 .Build.cs 文件中,需要添加Module:

1
2
3
4
5
6
7
8
9
PublicDependencyModuleNames.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
9
UENUM(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
19
void 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
7
void 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)。