【UE-DS】初识DS

初识DS

本篇作为DS入门文档,讲述了DS的构建过程,通过登录、射击游戏的例子对DS开发有一个初步的了解。
Demo链接:https://gitee.com/lixiang2202/uedemo_ds
文档理论性偏弱,操作性较强,建议跟随文档进行操作,收获更多。

Dedicated Server – 专用服务器

来自官方文档的介绍:在虚幻引擎中设置专用服务器 | 虚幻引擎 5.5 文档 | Epic Developer Community

1733758314967

在UE5中,客户端代码和服务器代码是一体的,一般通过 AActor中所维护的 TEnumAsByte<enum ENetRole> Role来区分代码在哪里运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** The network role of an actor on a local/remote network context */
UENUM(BlueprintType)
enum ENetRole : int
{
/** No role at all. */
ROLE_None UMETA(DisplayName = "None"),
/** Locally simulated proxy of this actor. 本地模拟的其他客户端的Actor*/
ROLE_SimulatedProxy UMETA(DisplayName = "Simulated Proxy"),
/** Locally autonomous proxy of this actor. 本机客户端控制的Actor*/
ROLE_AutonomousProxy UMETA(DisplayName = "Autonomous Proxy"),
/** Authoritative control over the actor. 服务器上的Actor*/
ROLE_Authority UMETA(DisplayName = "Authority"),
ROLE_MAX UMETA(Hidden),
};

Actor提供了 AActor::HasAuthority()方法来判断是否在服务器上。

1
2
3
4
5
bool AActor::HasAuthority() const
{
return (GetLocalRole() == ROLE_Authority);
}
ENetRole GetLocalRole() const { return Role; }

启用专用服务器

使用源码引擎

DS需要使用 Development Server编译模式,这个模式只有使用源码编译的UE5所创建的工程才有这个模式。
编译教程可以在网上搜,建议clone时使用ssh协议(git@github.com:EpicGames/UnrealEngine.git),比https协议更加稳定。

1733759516965

如仅需要测试DS,可以不使用源码编译的二进制引擎,可以跳转到文档最后射击小游戏的测试章节。
不过还是建议使用源码引擎体验一次发布流程。

创建项目

使用源码编译的引擎创建项目。本系列Demo采用第三人称模板C++项目(可以勾选StarterContent,后文例子中使用到了这里的特效资源)。

大多数模板中的Pawn和角色默认启用了复制。第三人称模板已拥有会自动复制移动的角色移动组件

1733759762445

构建项目

创建完成后,在Source目录下,新建 xxxServer.Target.csxxxClient.Target.cs

复制自动创建的 xxxEditor.Target.cs,然后将脚本内容中的 Editor字符串替换为 Server Client

Target.cs为UBT提供了对应构建目标类型的各种设置,可详见:虚幻引擎构建工具目标参考 | 虚幻引擎 5.5 文档 | Epic Developer Community

1733760279284

创建完成后,在IDE中(文档中使用的是rider)选择构建类型为Development Server,然后进行构建。构建完成后,选择类型为Development Client,进行构建。构建这两个目标耗时较久。

1733760341857

可以看到,在./UEDemo_DS/Binaries/Win64下生成了 UEDemo_DSServer.exeUEDemo_DSClient.exe。但此时还无法运行。

设置项目

构建完成后,选择构建模式为Development Editor,编译启动该项目编辑器。

因为项目创建时,选择了第三人称模板,所以在Content中可以看到一些资产。编辑器默认选择的level为 ThirdPersonMap,点击模拟播放后,可以运行第三人称模板的内容。

新建一个空level:TransitionMap,作为切换地图时的过渡level。

Project Settings -> Project -> Maps & Modes中,设置 Transition MapServer Default Map,如下图所示:
1733760901493

COOK资源

无论是client程序还是server程序,都需要cook资源才能够正确运行。在 PlatformsBUILD TARGET设置为 UEDemo_DSServer ,然后点击 Cook Content。成功后同理设置 BUILD TARGETUEDemo_DSClient

1733761188553

可以在 UEDemo_DS\Saved\Cooked目录下看到生成了Server和Client的cooked目录,两者的大小是不一样的,Server端cooked要小于Client端cooked。

1733761434228

运行专用服务器

此时可以运行专用服务器了。如果直接双击运行,会在后台运行Server,为了调试方便,建议为UEDemo_DSServer新建快捷方式,在快捷方式的目标栏中,添加-log参数(注意参数前要有空格)

1733761597251

双击快捷方式,可以看到打开一个控制台,这个就是DS服务器了。

打开UE客户端(编辑器模拟启动或者双击打开UEDemo_DSClient.exe),按下键盘’~’键,输入 open 127.0.0.1,登录到DS服务器上。

1733761766851

观察DS控制台,可以看到服务器接受了一个客户端的连接,并且使用的是服务器的地图。(注:可以复制一份ThirdPersonMap做以下修改,并在项目配置中的Server Default Map中修改服务器地图,观察登录DS服务器后客户端加载的地图变为了服务器地图)

1733762004906

此时再次启动一个客户端,登录DS服务器,可以在客户端上看到两个角色。下图分别使用了编辑器模拟和UEDemo_DSClient.exe打开了两个客户端,并通过open命令登录至服务器中。操作一个客户端的角色,在另一个客户端可以看到同步效果。

项目设置->引擎->一般设置->帧率,设置平滑帧率,限制最小帧率为30,可以解决失焦的客户端卡顿问题

1734359068906

1733762101357

简单应用

登录服务器

可以使用 APlayerController::ClientTravel接口登录服务器。新建 UUEDemo_DSGameInstance,继承自 UGameInstance,并在项目设置中指定。

1733846527722

新增连接DS服务器的方法,供蓝图使用

1
2
3
4
5
6
7
8
// 声明
UFUNCTION(BlueprintCallable)
void ConnectDedicatedServer(const FString& ServerIP);
// 实现
void UUEDemo_DSGameInstance::ConnectDedicatedServer(const FString& ServerIP)
{
GetWorld()->GetFirstPlayerController()->ClientTravel(ServerIP, ETravelType::TRAVEL_Absolute);
}

打开编辑器,新建一个登录界面 UMG_Login,布局如下:

1733847260352

在蓝图中实现点击按钮调用 UUEDemo_DSGameInstance::ConnectDedicatedServer

1733847342156

新建一个Level文件 ClientLevel,设置为客户端默认关卡:

1733847414585

在关卡蓝图中创建界面,并设置鼠标:

1733847468656

重新cook客户端和服务器资源后,启动服务器,客户端,可以看到登录界面,点击登录界面后登录到DS服务器中,客户端此时使用的是服务器的地图。

1733847619096

射击小游戏

使用UE的同步架构,实现一个简单的射击游戏,AB两个角色可以互相发射子弹,被击中后会掉血。这里血量就简单用屏幕日志来输出。
这个小游戏参考了官方文档(虚幻引擎多人游戏编程快速入门指南 | 虚幻引擎 5.5 文档 | Epic Developer Community),借此来简单熟悉UE的网络开发。

血量属性维护

AUEDemo_DSCharacter中新增血量属性,并在构造方法中进行初始化。

关于Actor的属性复制可详见:在虚幻引擎中复制Actor属性 本篇文档仅使用部分复制方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 玩家最大生命值,不复制
UPROPERTY(EditDefaultsOnly, Category="Health")
float MaxHealth;

// 玩家当前生命值,复制
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth)
float CurrentHealth;

// RepNotify, 同步CurrentHealth时调用
UFUNCTION()
void OnRep_CurrentHealth();

// 构造方法的实现
AUEDemo_DSCharacter::AUEDemo_DSCharacter()
{
//...
// 初始化角色的最大生命值和当前生命值
MaxHealth = 100.f;
CurrentHealth = MaxHealth;
}

重写 GetLifetimeReplicatedProps,注册配置 CurrentHealth的复制方式。

1
2
3
4
5
6
void AUEDemo_DSCharacter::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 复制当前生命值
DOREPLIFETIME(AUEDemo_DSCharacter, CurrentHealth);
}

声明并实现方法 void OnHealthUpdate(),响应要更新的生命值。修改属性后,在服务器立即调用,在客户端响应 OnRep_CurrentHealth方法,并在其中调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AUEDemo_DSCharacter::OnHealthUpdate()
{
// 客户端功能
if (IsLocallyControlled())
{
LOG_BLUE("Health changed to: %f", CurrentHealth);
if (CurrentHealth <= 0)
{
LOG_RED("you have been killed!");
}
}
// 服务器功能
if (HasAuthority())
{
LOG_BLUE("%s now has %f health remaining", *GetName(), CurrentHealth);
}
// 在所有机器上都执行的功能
LOG_GREEN("OnHealthUpdate called on %s, %f", *GetName(), CurrentHealth);
}

LOG_XXX方法实际是用宏封装了 GEngine->AddOnScreenDebugMessage()UE_LOG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma once

DECLARE_LOG_CATEGORY_EXTERN(LogUEDemoDS, Log, All);
DEFINE_LOG_CATEGORY(LogUEDemoDS);

#define LOG_PRINT(Color, Format, ...) \
{ \
if (GEngine){ GEngine->AddOnScreenDebugMessage(-1, 5.f, Color, FString::Printf(TEXT(Format), ##__VA_ARGS__));} \
UE_LOG(LogUEDemoDS, Error, TEXT(Format), ##__VA_ARGS__); \
}
#define LOG_RED(Format, ...) LOG_PRINT(FColor::Red, Format, ##__VA_ARGS__)
#define LOG_GREEN(Format, ...) LOG_PRINT(FColor::Green, Format, ##__VA_ARGS__)
#define LOG_BLUE(Format, ...) LOG_PRINT(FColor::Blue, Format, ##__VA_ARGS__)
#define LOG_YELLOW(Format, ...) LOG_PRINT(FColor::Yellow, Format, ##__VA_ARGS__)

OnRep_CurrentHealth()中调用 OnHealthUpdate()方法

1
2
3
4
5
void AUEDemo_DSCharacter::OnRep_CurrentHealth()
{
LOG_YELLOW("OnRep_CurrentHealth called on %s", *GetName());
OnHealthUpdate();
}

实现 SetCurrentHealthTakeDamage方法,用于响应伤害。
SetCurrentHealth()提供了一个可控的办法从外部修改玩家 CurrentHealth,通过判断 HasAuthority()来限定仅在服务器上执行此函数,同时调用了 OnHealUpdate()以确保客户端和服务器对其都有调用。
TakeDamage()方法重写自 APawn,作为外部Actor对其造成伤害后的逻辑触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void AUEDemo_DSCharacter::SetCurrentHealth(float healthValue)
{
if (HasAuthority())
{
LOG_YELLOW("SetCurrentHealth called on %s", *GetName());
CurrentHealth = FMath::Clamp(healthValue, 0.f, MaxHealth);
OnHealthUpdate();
}
}

float AUEDemo_DSCharacter::TakeDamage(float DamageTaken, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float damageApplied = CurrentHealth - DamageTaken;
SetCurrentHealth(damageApplied);
return damageApplied;
}

测试属性同步

临时在 Jump方法中模拟造成伤害,主动调用 TakeDamage(),对属性同步进行测试。

1
2
3
4
5
6
void AUEDemo_DSCharacter::Jump()
{
Super::Jump();
FDamageEvent damageEvent(UDamageType::StaticClass());
TakeDamage(10.f, damageEvent, nullptr, nullptr); // 造成10点伤害
}

重新编译 ServerClient,启动2个客户端并登录至服务器,一个客户端角色跳跃,观察另外客户端日志和服务器日志,可以看到属性同步的结果。

1733932027592

如图所示,1号客户端、2号客户端登录在同一个服务器上,1号客户端跳跃:

  • 服务器执行方法 SetCurrentHealth响应处理了伤害,并同步给1、2号客户端
  • 1号客户端响应 OnRep_CurrentHealth并执行 OnHealthUpdate方法。因为是本地客户端 IsLocallyControlled,所以输出了蓝色日志和绿色日志
  • 2号客户端响应 OnRep_CurrentHealth并执行 OnHealthUpdate方法。仅输出了通用的绿色日志

使用复制创建抛射物

接下来,为角色创建一个发射抛射物的功能。

  • 首先新建一个抛射物Actor:AUEDemo_DSProjectile
    在构造方法中设置 bReplicates = true,表示这个Actor需要被复制。
    在头文件中做如下定义:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 用于测试碰撞的球体组件。
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class USphereComponent* SphereComponent;

    // 用于提供对象视觉呈现效果的静态网格体。
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class UStaticMeshComponent* StaticMesh;

    // 用于处理投射物移动的移动组件。
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class UProjectileMovementComponent* ProjectileMovementComponent;

    // 在投射物撞击其他对象并爆炸时使用的粒子。
    UPROPERTY(EditAnywhere, Category = "Effects")
    class UParticleSystem* ExplosionEffect;

    //此投射物将造成的伤害类型和伤害。
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Damage")
    TSubclassOf<class UDamageType> DamageType;

    //此投射物造成的伤害。
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Damage")
    float Damage;
  • 在构造方法中做如下初始化

    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
    //	定义根节点及碰撞信息
    SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("SphereComponent"));
    SphereComponent->InitSphereRadius(37.5f);
    SphereComponent->SetCollisionProfileName(TEXT("BlockAllDynamic"));
    RootComponent = SphereComponent;
    if (this->HasAuthority())
    { // 仅在服务器端添加碰撞回调
    SphereComponent->OnComponentHit.AddDynamic(this, &AUEDemo_DSProjectile::OnProjectileImpact);
    }
    // 定义静态网格体
    static ConstructorHelpers::FObjectFinder<UStaticMesh> DefaultMesh(TEXT("/Game/StarterContent/Shapes/Shape_Sphere.Shape_Sphere"));
    StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
    StaticMesh->SetupAttachment(RootComponent);
    if (DefaultMesh.Succeeded())
    {
    StaticMesh->SetStaticMesh(DefaultMesh.Object);
    StaticMesh->SetRelativeLocation(FVector(0.f, 0.f, -37.5f));
    StaticMesh->SetRelativeScale3D(FVector(0.75f, 0.75f, 0.75f));
    }
    // 定义特效
    static ConstructorHelpers::FObjectFinder<UParticleSystem> DefaultExplosionEffect(TEXT("/Game/StarterContent/Particles/P_Explosion.P_Explosion"));
    if (DefaultExplosionEffect.Succeeded())
    {
    ExplosionEffect = DefaultExplosionEffect.Object;
    }
    // 定义移动组件
    ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovement"));
    ProjectileMovementComponent->SetUpdatedComponent(SphereComponent);
    ProjectileMovementComponent->InitialSpeed = 1500.f;
    ProjectileMovementComponent->MaxSpeed = 1500.f;
    ProjectileMovementComponent->bRotationFollowsVelocity = true;
    ProjectileMovementComponent->ProjectileGravityScale = 0.f;
    // 定义伤害类型和伤害
    DamageType = UDamageType::StaticClass();
    Damage = 10.f; // 伤害数值
  • 伤害反馈
    我们希望抛射物在命中时立即销毁并播放一个爆炸特效。重写 Destoryed方法,该方法在Actor销毁时调用。

    1
    2
    3
    4
    5
    6
    7
    8
    virtual void Destroyed() override;
    void AUEDemo_DSProjectile::Destroyed()
    {
    Super::Destroyed();
    // 销毁时播放爆炸特效
    FVector spawnLocation = GetActorLocation();
    UGameplayStatics::SpawnEmitterAtLocation(this, ExplosionEffect, spawnLocation, FRotator::ZeroRotator, true, EPSCPoolMethod::AutoRelease);
    }
  • 碰撞回调
    声明并实现碰撞回调方法,并绑定碰撞回调,注意仅在服务器端绑定。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 在头文件中做如下定义
    UFUNCTION(Category=Projectile)
    void OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComponent, FVector NormalImpulse, const FHitResult& Hit);
    void AUEDemo_DSProjectile::OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComponent, FVector NormalImpulse, const FHitResult& Hit)
    {
    // 碰撞回调
    if (OtherActor)
    {
    // 应用伤害,最终会调用到TakeDamage函数
    UGameplayStatics::ApplyPointDamage(OtherActor, Damage, NormalImpulse, Hit, GetInstigator()->Controller, this, DamageType);
    }
    // 销毁自身
    Destroy();
    }
    // 在构造方法中为SphereComponent绑定碰撞回调
    if (this->HasAuthority())
    { // 仅在服务器端添加碰撞回调
    SphereComponent->OnComponentHit.AddDynamic(this, &AUEDemo_DSProjectile::OnProjectileImpact);
    }
  • 创建抛射物
    在角色类 AUEDemo_DSCharacter中创建抛射物对象。
    每一次设计,都会通过RPC调用到服务器的创建抛射物接口,为防止消息过于频繁,做了每个抛射物生成的延时机制,下面是关键代码:
    在头文件中做如下声明:

    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
    UPROPERTY(EditDefaultsOnly, Category="Gameplay|Projectile")
    TSubclassOf<class AUEDemo_DSProjectile> ProjectileClass;

    /** 射击之间的延迟,单位为秒。用于控制测试发射物的射击速度,还可防止服务器函数的溢出导致将SpawnProjectile直接绑定至输入。*/
    UPROPERTY(EditDefaultsOnly, Category="Gameplay")
    float FireRate;

    /** 若为true,则正在发射投射物。*/
    bool bIsFiringWeapon;

    /** 用于启动武器射击的函数。*/
    UFUNCTION(BlueprintCallable, Category="Gameplay")
    void StartFire();

    /** 用于结束武器射击的函数。一旦调用这段代码,玩家可再次使用StartFire。*/
    UFUNCTION(BlueprintCallable, Category = "Gameplay")
    void StopFire();

    /** 用于生成投射物的服务器函数。注意该方法声明为服务器RPC,需要实现名为HandleFire_Implementation的方法,客户端调用该方法实际是在服务器端运行。
    本篇不对RPC做深入研究,只做了解。*/
    UFUNCTION(Server, Reliable)
    void HandleFire();

    /** 定时器句柄,用于提供生成间隔时间内的射速延迟。*/
    FTimerHandle FiringTimer;

    在cpp中实现:

    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
    void AUEDemo_DSCharacter::StartFire()
    { // 按键绑定鼠标左键,这里忽略了绑定过程,可查看demo了解完整过程
    if (!bIsFiringWeapon) // 用该标记做防重入检测
    {
    bIsFiringWeapon = true;
    GetWorld()->GetTimerManager().SetTimer(FiringTimer, this, &AUEDemo_DSCharacter::StopFire, FireRate, true);
    HandleFire();
    }
    }

    void AUEDemo_DSCharacter::StopFire()
    {
    bIsFiringWeapon = false;
    }

    void AUEDemo_DSCharacter::HandleFire_Implementation()
    {
    // 生成位置、旋转
    FVector spawnLocation = GetActorLocation() + ( GetActorRotation() Vector() * 100.0f ) + (GetActorUpVector() * 50.0f);
    FRotator spawnRotation = GetActorRotation();
    // 生成参数
    FActorSpawnParameters spawnParameters;
    spawnParameters.Instigator = GetInstigator();
    spawnParameters.Owner = this;
    // 调用SpawnActor生成投射物
    AUEDemo_DSProjectile* spawnedProjectile = GetWorld( ->SpawnActor<AUEDemo_DSProjectile>(spawnLocation, spawnRotation, spawnParameters);
    }

测试

编译完成后,启动服务器和两个客户端,在客户端中,按下鼠标左键,可以看到生成一个抛射物并向前运动,直到发生了碰撞,销毁自身并播放了一个爆炸特效。
当碰撞到另外一个角色时,执行了 UGameplayStatics::ApplyPointDamage(),并传递至对方角色的 AUEDemo_DSCharacter::TakeDamage()方法,造成伤害后打印了伤害日志。

更加方便的启动测试方式(注:此种方式无需使用源码引擎,但默认会自动连接DS服务器,所以启动后无法看到登录界面):

虚幻引擎中的在编辑器中运行多玩家选项 | 虚幻引擎 5.5 文档 | Epic Developer Community

在模拟选项中,选择玩家数量为 2,网络模式为 客户端模式

1734358094230

打开高级设置,或 编辑器偏好设置->多玩家选项中做如下设置:

注意要设置服务器地图命名重载为 ThirdPersonMap,启动参数为 -server -log

1734358887796

设置完毕后,点击模拟播放:

1734359143666

总结

通过本篇文档,可以对DS做一个初步的入门了解(建议参照文档实操一下),因为只是入门文档,所以没有做深入的技术研究。

后续将会以网络开发为主题,对Actor复制和RPC、GamePlay、UE网络模块、DS优化、完整的服务器拓扑结构等进行较为深入的学习。

参考资料

ue4-Network相关-Client和Server的区分_ue4 client server-CSDN博客

【UE5】UE5 Dedicated Server专用服务器与网络同步-CSDN博客

虚幻引擎中的在编辑器中运行多玩家选项 | 虚幻引擎 5.5 文档 | Epic Developer Community


【UE-DS】初识DS
https://lixiang2202.github.io/2024/12/16/link/UE/DS/1_初识DS/1_初识DS/
作者
Li Xiang
发布于
2024年12月16日
许可协议