zl程序教程

您现在的位置是:首页 >  硬件

当前栏目

【ue4】【使用】DS服务器搭建

服务器 使用 搭建 UE4 DS
2023-06-13 09:14:16 时间

前言

UE4 专用DS服务器(Standalone Dedicated Server)提供了客户端/服务器模型的网络连接。

这里使用 UE4 自带的 Actor复制(Replicated)RPC 实现一个可以在局域网环境下进行联机的功能。

并同步显示一些信息,如__当前在线的玩家数__,__玩家名称列表__等。

实现流程大纲如下

创建登录界面 -- 可以输入用户名, IP, 端口号等登录信息 创建游戏主场景 -- 也是服务器的默认场景 使用 ClientTravel 建立连接 编译DS服务器,并进行连接测试 同步显示当前在线人数 和 玩家名称列表

先看结果,再谈过程

到这一步会实现一个简单的局域网联机功能,并能显示当前在线用户信息。

下一步添加人物血量和伤害计算之后就可以开打了。

原理篇

Actor -- 基本的复制单位

Replication

Replication 指从__服务器__向__客户端__发送数据的__单向__行为,即数据不会从客户端传向服务器。

如果一个 Actor 被设置为 Replicates,那么将会出现两种情况 - 此 Actor 是在服务端被生成的 -- 它会被所有的客户端生成 - 此 Actor 是在客户端被生成的 -- 它只会在其所在客户端生成

如果需要同步一个 Actor 里的 Actor 变量,则需要将后者本身及指向它的指针变量都设置为 Replication才行。

静态生成的 Actor (编辑器阶段) 和 动态生成的 Actor (游戏运行阶段Spawn) 的同步状态是不同的。

对于在 Editor 中放置的 Actor, - 如果它是 Replicates 的, 那么客户端和服务器就都有一个 Actor,而且是同步的 - 如果它不是 Replicates 的, 那么客户端和服务器也都有一个 Actor, 但是不同步的 - 注意 Replicate Movement 不会自动根据 Replicates 调整

对于在游戏运行时 Spawn 出来的 Actor,且是 Replicates 的 - 如果它是在客户端 Spawn 出来的,那么不用说肯定只有此客户端才有 - 如果它是在服务端 Spawn 出来的, 那么它会在同步到所有的客户端 - 如果 Spawn 它的 Actor 也在客户端存在,如 关卡蓝图 等,那么 - 服务端的会 Spawn 出来并同步到所有的客户端 - 客户端的会 Spawn 出来但只出现在本地 - 这样的结果就是服务端只会看到一个 Actor (即它自己生成的), 而客户端会看到两个 Actor (服务端同步过来的 和 它自己生成的) - 常常使用 Role == ROLE_Authority 或者蓝图节点 Switch has authority 来保证只在服务端生成 Actor

Role

Role 表示当前端对 Actor 的控制权,主要有 Authority, Simulated_ProxyAutonomouse_Proxy 三种

Authority -- 主控

服务端对所有的 Actor 都是 Authority 的, 包括自己控制的 Actor 和 客户端控制的 Actor。

服务端自己控制的 Actor 在其他客户端上是 Simulated 的。

而服务端上其他玩家的 Actor 也是通过 Pawn 提交到服务端进行计算后再次同步给所有客户端。

Autonomouse_Proxy -- 自治代理

客户端自己控制的 Actor 是 Autonomouose_Proxy, 其它的都是 Simulated_Proxy 的。

Autonomouse 相当于一种更平滑的同步方式,它可以首先获得输入,在收到服务端传来的覆盖信息之前就平滑地模拟移动。

Simulated_Proxy -- 模拟代理

Simulated 只存在于客户端, 它无法受到自己的控制,只接受从服务器同步来的数据。

所以它既可能是服务器控制的,也可能是其他客户端控制的。

与 Autonomouse 不同的是, Simulated 更像是一种 强拉 的同步方式,即以最后的状态去移动物体。

但是我们可以通过自己的插值算法来填补空缺。

判断

在 C++ 里,我们可以直接使用 Role == ROLE_xxx 来进行判断一个 Pawn 的 Role 类型。

在 蓝图里, 通过 Has Authority 来确定是否是服务端,如果不是,则通过判断 pawn 是否是 IsLocallyControlled 来确定是否是 Autonomouse。

Property Replication -- 属性复制

即使一个 Actor 被标记为 Replicates 了, 它的某个属性可能也是不能复制的,这个时候我们需要自己设置其属性为 replicated

此时如果此属性在服务端发生了变化,那么它就会被同步到其他客户端上去。

比较常见的就是玩家的 health

class ADCharacter : ACharacter 
{
public:
	UPROPERTY( replicated )
	float m_health;
}

这样还不够,还需要实现 GetLifetimeReplicatedProps() 函数,并执行想要复制的属性的复制生命周期,就像下面代码所示

void ADCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ADCharacter, m_health);
}

条件属性复制

(待补)

RPC -- 远程过程调用

RPC是在本地调用但在其他机器上远程执行的函数。

分为 Client, Server, NetMulticast 三种。

执行 PRC 的条件

  • RPC 必须在 Actor 上调用
    • AGameMode APlayerController ACharacter ALevelScriptActor 都可以
  • 此 Actor 必须是 Replicates 的。
  • RPC 无返回值 -- 所以蓝图里只能标记在 CustomEvent 上。

Client -- 服务器调用,客户端执行

只在拥有这个 Actor 的客户端上执行 -- Autonomous 和 Authority 都可以。

比如说,服务器发起开始游戏的指令,就可以通过 Client 类型的 RPC 通知给客户端。

Server -- 客户端调用,服务器执行

客户端拥有对此 Actor 的 Autonomous, 服务器拥有对此 Actor 的 Authority。

比如说,客户端想执行某个动作(如Attack),就需要通过 Server 类型的 PRC 向服务器发起调用申请,因为服务器对其拥有 Authority 权,所以最终会在服务器中执行。

class ADCharacter: public ACharacter
{
public:
	void on_attack();
	UFUNCTION(reliable, server, WithValidation)
	void server_on_attack();
}
// reliable 是为了让 RPC 可靠(本来是不可靠的)
// server 表示 RPC 的类型
// WithValidation 表示需要验证,只有验证为 true 时才进行远程调用,否则断线
void ADCharacter::on_attack() 
{
	server_on_attack();				// 这会向服务器发起调用请求
}

bool ADCharacter::server_on_attack_Validate() 
{
	if(...) return false;
	return true;
}

void ADCharacter::server_on_attack_Implementation()
{
	if (Role == ROLE_Authority) {
		...
	}
}
// 这里只实现 xxx_Validate() 和 xxx_Implementation() 即可

NetMulticast -- 多播

如果多播RPC是从服务器调用的,则在服务器上以及所有与其连接的客户端上都会执行。

如果多播RPC是从客户端调用的,则只在本地客户端执行。

Server or Client, that's a question

  • GameMode 只存在于 Server 上,作为整场游戏的管理员存在
    • 所以 GameMode 里面不能进行属性的同步
  • GameState 存在于服务器和客户端上,所以可以通过 GameState 同步一些属性,尤其是全局的属性
  • PlayerController
    • 存在于服务器上的适用于所有客户端。
    • 存在于客户端上的只适用于本地客户端。
    • 不适合存储所有客户端都需要的数据
  • PlayerState 存在于服务器和客户端上
    • 适用于所有的客户端
    • 可以用来存储所有客户端都需要的属性,如某个玩家的当前分数
  • Pawn 存在于服务器和客户端上
    • 玩家死亡之后 Pawn 会被销毁,并在重生时重建,所以有些需要一直保存下去的数据需要存在PlayerState 里

多人游戏中的关卡切换

官链

非无缝切换

是一种阻塞(blocking)操作。

当进行非无缝切换时,客户端会与服务器断开连接,然后重新连接,服务器重新加载地图。

在初次加载地图时、客户端初次连接到服务器时、服务器想终止一场游戏时,都一定会产生非无缝转移。

有三个函数可以实现转移 -- UEngine::Browser UWorld::ServerTravel APlayerController::ClientTravel

UEngine::Browser

非无缝切换

服务器切换到目标地图

客户端断开连接

DS服务器无法切换至其他服务器 -- 地图必须是本地地图

UWorld::ServerTravel

仅适用于服务器 -- 看名字也知道

服务器为所有客户端执行 APlayerController::ClientTravel

所有客户端会跟随服务器进入新的地图

APlayerController::ClientTravel

如果从服务器调用,则要求特定的客户端转移到新的地图 -- 但没有断开连接

如果从客户端调用,则转换到新的服务器(新的地图)

无缝切换

是一种非阻塞(non-blocking)操作

需要设置过渡关卡 UGameMapsSettings::TransitionMap

需要将 AGameMode::bUseSeamlessTravel 设置为 true

在加载新关卡前,旧地图需要保留的 Actor 转移到过渡关卡

切换流程

标记出要在过渡关卡中存留的 actor(更多信息请见下面) 转移到过渡关卡 标记出要在最终关卡中存留的 actor(更多信息请见下面) 转移到最终关卡

简单起见,我们这里客户端使用 ClientTravel 进行切换关卡并连接到服务器。

因为研究这个东西的周期比较短,上面总结的都是一些比较基础的东西,也会有许多不正确的地方,至于需要更加细节化的地方,会放到以后翅膀硬了再作补充。

但是通过对上面这些知识的利用,我们就可以很轻松地实习一个可以通过DS服务器进行联机并同步一些数据的小demo。

搭建过程

欢迎界面

首先做好欢迎界面和登录界面的UI,这里使用 UMG 制作。

这里的欢迎界面是一个单独的关卡, 我们固定 PlayerControllerPlayerCameraManager, 然后使用一个 Plane, 并给其贴图作为我们的背景

void ADMainPlayerController::set_camera_pos()
{
	PlayerCameraManager->SetActorLocation(FVector(0.f, 0.f, 0.f));
	SetViewTarget(PlayerCameraManager);
	bShowMouseCursor = true;
}

菜单使用 UMG 制件

在登录界面输入相关的 用户名(用于后面显示)、IP地址、端口号之后,点击登录,会触发事件 ClientTravel()

从而登录到 DS服务器 上

void ADMainPlayerController::on_login_login_bt()
{
	if (m_login_ui->m_username_text->GetText().ToString().IsEmpty()) {
		UDUtility::debug_out(TEXT("请输入用户名先"));
		return;
	}

	FString url = FString::Printf(TEXT("%s:%s?Alias=%s"), 
								*(m_login_ui->m_ip_text->GetText().ToString()), 
								*(m_login_ui->m_port_text->GetText().ToString()), 
								*(m_login_ui->m_username_text->GetText().ToString()));
	UDUtility::log(url);
	ClientTravel(*url, TRAVEL_Absolute);

}

我们的这个欢迎场景有独有的 GameMode 和 PlayerController,在进入主游戏场景之后,会有另外游戏时的 GameMode 和 PlayerController

打包测试

如果不管同步的事情的话,我们的客户端就已经可以很友好地登录到服务器,然后互相看到对方了。

我们可以先打包测试一下,然后再讨论同步的问题。

编译源码

听说只有通过UE4源码才能编译出DS服务器,也不知道是不是真的,还好我一直用的都是编译出来的版本。

编译源码的步骤可移步之前编译的时候留下的摘记 =》ue4有意思之编译源码

构建服务器

在打包之前,我们要设置好 Server 的默认地图,不然玩家 ClientTravel() 之后岂不是要掉侧所里去了。

用 VS 打开工程,将 解决方案配置 改为 Development Server

编译,在 工程目录\Binaries\Win64 下得到 xxxServer.exe

这就是我们构建服务器的最终产物,我们把它拷出来,仔细保管为妙。

构造客户端

在打包之前也要设置好默认地图

同样用 VS 打开工程,将 解决方案配置 分别改为 DevelopmentDevelopment Editor 编译一次,保险起见

然后在 Editor 里打包即可

测试

我们将之前妥善保管的 xxxServer.exe 放到 打包目录/WindowsNoEditor/DGame/Binaries/Win64 文件夹下

此文件夹下应该有 xxx.exe 即我们的客户端可执行文件。

然后创建 xxxServer.exe 的快捷方式,并右键属性,在其路径后面加上 -log 以使服务器运行的时候可以显示 log 信息

打开 xxxServer.exe , 会看到日志信息

注意检查有没有正在监听端口

打开多个 xxx.exe, 最好在同一局域网下的另外的电脑上也打开几个客户端,以便测试

点击登录即可互相看到对方,这是完成下面的同步之后的结果

同步

同步显示在线人数

在这之前,我决定沉思一下,当前的在线人数这个变量,到底是属于哪个层次的。

首先,肯定不是属于某个玩家的,自然不能放在 PlayerController 或者 Character 里面。

如果每个 PlayerController 都存一个副本, 那每次有玩家登录处理起来也忒复杂,不好不好。

其次,肯定也不能放在 GameMode 里面, 因为 GameMode 只在服务端存在,GameMode 里面的变量没法复制。

那么 GameState 呢,我觉得,没毛病,不妨一试。

于是我在 GameState 里声明了这样一个变量表示当前的在线人数

class ADGameState : public AGameState
{
public:
	UPROPERTY(replicated)
	int m_player_cnt;
}

void ADGameState::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ADGameState, m_player_cnt);
}

接着我们自然要重写 GameMode 的 PostLogin() 函数 和 Logout() 函数,在玩家登录或退出的时候改变这个变量。

void ADGameMode::PostLogin(APlayerController* NewPlayer)
{
	Super::PostLogin(NewPlayer);
	if (ADGameState* state = GetGameState<ADGameState>()) { 
		...
		++state->m_player_cnt; 
	}
}

void ADGameMode::Logout(AController* Exiting)
{
	if (ADGameState* state = GetGameState<ADGameState>()) {
		--state->m_player_cnt; 
	}
	Super::Logout(Exiting);
}

下面要做的就是把这个变量显示到每个客户端的 ui 上了

我们在 每一个拥有 ROLE_AutonomousProxyPlayerController 里面更新 ui 的显示

if (Role == ROLE_AutonomousProxy && NM_Client == GetNetMode()) {
	if (ADGameState* state = Cast<ADGameState>(GetWorld()->GetGameState())) {
		m_start_ui->m_hc_text->SetText(FText::FromString(FString::FromInt(state->m_player_cnt)));
	}	
}

其实也可以在每次玩家登录或登出的时候调用每一个 PlayerController 上的 一个 Client 类型 的RPC 函数,这个 RPC 函数向每一个玩家发送改变 ui 值的信息

同步显示玩家列表

有了上面的经验,我们可以同样地来显示玩家的名称列表,只不过有几点需要注意的地方。

首先,在 UE4 封装的模板类型中,只有 TArray<> 是可以复制的,一开始用 TMap<> 被坑的好惨。

其次,用户名信息可以在 ClientTravel() 时通过 url参数直接传递过去。


void ADMainPlayerController::on_login_login_bt()
{
	if (m_login_ui->m_username_text->GetText().ToString().IsEmpty()) {
		UDUtility::debug_out(TEXT("请输入用户名先"));
		return;
	}

	FString url = FString::Printf(TEXT("%s:%s?Alias=%s"), 
								*(m_login_ui->m_ip_text->GetText().ToString()), 
								*(m_login_ui->m_port_text->GetText().ToString()), 
								*(m_login_ui->m_username_text->GetText().ToString()));
	UDUtility::log(url);
	ClientTravel(*url, TRAVEL_Absolute);

}

在 GameMode 中重写 InitNewPlayer() 函数来获得传过来的 url 参数

FString ADGameMode::InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal)
{
	FString res = Super::InitNewPlayer(NewPlayerController, UniqueId, Options, Portal);
	if (ADPlayerController* pc = Cast<ADPlayerController>(NewPlayerController))
	{
		FString alias = UGameplayStatics::ParseOption(Options, TEXT("Alias")).TrimStartAndEnd();
		if (alias.Len() == 0 || m_players.Find(alias)) { return res; }
		pc->m_name = alias;						// 获得 controller 的名字

		if (ADGameState* state = GetGameState<ADGameState>()) {
			state->m_player_names.Add(alias);
			//for (auto a : state->m_player_names) { UDUtility::debug_out(a); }
		}

		FPlayerData tpd; tpd.m_name = alias;
		m_players.Add(alias, tpd);
	}
	return res;

}

更多尝试

Steam联机

Steam, Using the Steam SDK During Development

Online Subsystem Steam(4.17版本)

UE4 提供了连接 Steam 平台的插件,当前的版本甚至都集成了 Steam SDK, 不用自己重新下载编译了。

大体流程如下 - 修改 Build.cs

PublicDependencyModuleNames.AddRange(new string[] { 
	"OnlineSubsystem",
	"OnlineSubsystemUtils"
});

- 修改 Target.cs

bUsesSteam = true;
  • 修改 DefaultEngine.ini
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")
 
[OnlineSubsystem]
DefaultPlatformService=Steam
PollingIntervalInMs=20
 
[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480
GameServerQueryPort=27015
bRelaunchInSteam=false
GameVersion=1.0.0.0
bVACEnabled=1
bAllowP2PPacketRelay=true
P2PConnectionTimeout=90
 
[/Script/OnlineSubsystemSteam.SteamNetDriver]
NetConnectionClassName="/Script/OnlineSubsystemSteam.SteamNetConnection"

KBE

KBEngine是一款开源的游戏服务端引擎,使用简单的约定协议就能够使客户端与服务端进行交互。

KEB 的 CBE 模块可支持多种游戏引擎的客户端编程,当然也包括UE4。

通过 CBE 的一些 API 可与 UE4 客户端进行通信。

参考资料

官链_Networking and Multiplayer

Dedicated Server Guide

Unreal Engine 4 入门学习 - 网络通信应用场景 -- 小姐姐讲的特别通俗易懂

从零开始搭建一个UE4(虚幻引擎)的联网Demo:服务器端和客户端

KBEngine(服务端) + UE4(unreal engine4客户端)demo搭建