Skip to content

Latest commit

 

History

History
1235 lines (795 loc) · 70.4 KB

File metadata and controls

1235 lines (795 loc) · 70.4 KB

十六、多人游戏基础

概观

在这一章中,你将被介绍一些重要的多人游戏概念,以便使用虚幻引擎 4 的网络框架为你的游戏增加多人游戏支持。

到本章结束时,您将了解基本的多人游戏概念,如服务器-客户端体系结构、连接和参与者所有权,以及角色和变量复制。您将能够实现这些概念来创建一个自己的多人游戏。您还可以制作一个 2D 混合空间,它允许您在 2D 网格中的动画之间进行混合。最后,您将学习如何在运行时使用Transform (Modify) Bone节点来控制骨骼网格骨骼。

简介

在前一章中,我们完成了SuperSideScroller游戏,并使用了 1D 混合空间、动画蓝图和动画蒙太奇。在本章中,我们将在这些知识的基础上学习如何使用虚幻引擎为游戏添加多人游戏功能。

多人游戏在过去的十年里发展了很多。诸如堡垒之夜、PUBG、传奇联盟、火箭联盟、Overwatch 和 CS: GO 等游戏在游戏社区获得了大量的人气,并取得了巨大的成功。如今,几乎所有的游戏都需要某种多人游戏体验,才能更加贴切和成功。

究其原因,是它在现有游戏玩法的基础上增加了一层新的可能性,比如可以在合作模式(也叫 co-op 模式)下和朋友一起玩,或者和来自世界各地的人对战,大大增加了一款游戏的寿命和价值。

在下一个主题中,我们将讨论多人游戏的基础。

多人基础

你可能在玩游戏的时候经常听到多人游戏这个术语,但是它对游戏开发者来说意味着什么呢?多人游戏,在现实中,只是服务器和其连接的客户端之间通过网络(互联网或局域网)发送的一组指令,目的是给玩家一种共享世界的错觉。

要做到这一点,服务器需要能够与客户端对话,但也需要能够与客户端对话(客户端到服务器)。这是因为客户端通常会影响游戏世界,所以他们需要一种方法能够在玩游戏时通知服务器他们的意图。

服务器和客户端之间这种来回通信的一个例子是当玩家在游戏中试图发射武器时。请看下图,它显示了客户端-服务器交互:

Figure 16.1: Client-server interaction when a player wants to fire  a weapon in a multiplayer game

图 16.1:当玩家想要在多人游戏中发射武器时的客户端-服务器交互

我们来看看图 16.1 中显示了什么:

  1. 玩家按住鼠标左键,该玩家的客户端告诉服务器想要发射武器。
  2. 服务器通过检查以下内容来验证玩家是否可以发射武器:
    • 如果玩家还活着
    • 如果玩家装备了武器
    • 如果玩家有足够的弹药
  3. 如果所有验证都有效,则服务器将执行以下操作:
    • 运行逻辑来扣除弹药
    • 在服务器上生成投射执行元,它会自动发送给所有客户端
    • 在所有客户端中的角色实例上播放 fire 动画,以确保所有客户端之间的同步性,这有助于推销这是同一个世界的想法,尽管事实并非如此
  4. 如果任何验证失败,服务器会告诉特定的客户端该做什么:
    • 玩家死了,不要做任何事
    • 玩家没有装备武器–不要做任何事情
    • 玩家没有足够的弹药-播放空的咔哒声

请记住,如果您希望您的游戏支持多人游戏,那么强烈建议您在开发周期中尽快这样做。如果你尝试运行一个多人模式的单人项目,你会注意到有些功能可能只是工作,但可能大部分功能都不能正常工作或如预期的那样。

其原因是,当你在单人模式下执行游戏时,代码会在本地即时运行,但当你将多人模式加入到等式中时,你就加入了外部因素,比如一个权威服务器,它会在网络上与客户端进行具有延迟的对话,正如你在图 16.1 中看到的那样。

为了让一切正常工作,您需要将现有代码分解为以下内容:

  • 只在服务器上运行的代码
  • 只在客户端运行的代码
  • 运行在两者上的代码,这可能需要很多时间,这取决于你的单人游戏的复杂性

为了给游戏增加多人支持,虚幻引擎 4 自带了一个已经内置的非常强大且带宽高效的网络框架,采用了权威的服务器-客户端架构。

下面是它的工作原理:

Figure 16.2: Server-client architecture in Unreal Engine 4

图 16.2:虚幻引擎 4 中的服务器-客户端架构

图 16.2 中,可以看到服务器-客户端架构在虚幻引擎 4 中是如何工作的。每个玩家控制一个客户端,该客户端使用双向连接与服务器通信。服务器以一种游戏模式(只存在于服务器中)运行一个特定的关卡,控制信息流,让客户端在游戏世界中可以看到并相互交互。

注意

多人游戏可能是一个非常高级的话题,所以接下来的几章将作为一个介绍来帮助你理解要点,但它不会是一个深入的了解。因此,为了简单起见,可能会省略一些概念。

在下一节中,我们将研究服务器。

服务器

服务器是架构中最关键的部分,因为它负责处理大部分工作并做出重要决策。

以下是服务器主要职责的概述:

  1. 创建和管理共享世界实例:服务器在特定的级别和游戏模式下运行自己的游戏实例(这将在后面的章节中介绍),并将作为所有连接的客户端之间的共享世界。正在使用的级别可以在任何时间点更改,如果适用,服务器可以自动将所有连接的客户端一起带来。

  2. Handling client join and leave requests: If a client wants to connect to a server, it needs to ask for permission. To do this, the client sends a join request to the server, through a direct IP connection (explained in the next section) or an online subsystem such as Steam. Once the join request reaches the server, it will perform some validations to determine whether the request is accepted or rejected.

    但是,您应该知道服务器拒绝加入游戏的请求有几个原因。最常见的情况是服务器已经满负荷,不能再接收更多的客户端,或者客户端使用的是过时的游戏版本。如果服务器接受请求,那么具有连接的玩家控制器被分配给客户端,并且游戏模式中的PostLogin功能被调用。从那时起,客户端将进入游戏,并成为共享世界的一部分,玩家将能够看到并与其他客户端互动。如果一个客户端在任何时间点断开连接,那么将通知所有其他客户端,并调用游戏模式下的Logout功能。

  3. Spawning the actors that all of the clients need to know about: If you want to spawn an actor that exists in all of the clients, then you need to do that on the server. The reason for this is the server has the authority and is the only one that can tell each client to create its own instance of that actor.

    这是多人游戏中产生角色的最常见方式,因为大多数角色需要存在于所有客户端中。这方面的一个例子是加电,所有客户端都可以看到并与之交互。

  4. 运行关键玩法逻辑:为了保证游戏对所有客户端都是公平的,关键玩法逻辑只需要在服务器端执行即可。如果客户端负责处理生命值的扣除,那将是非常可利用的,因为玩家可以使用一个工具在内存中一直将当前生命值更改为 100%,这样玩家就永远不会在游戏中死亡。

  5. 处理变量复制:如果您有一个复制的变量(在本章中介绍),那么它的值应该只在服务器上更改。这将确保所有客户端的值都自动更新。您仍然可以更改客户端上的值,但它将始终被服务器上的最新值替换,以防止作弊并确保所有客户端同步。

  6. 处理来自客户端的 RPC:服务器需要处理来自客户端的远程过程调用(第 17 章远程过程调用)。

现在您已经知道了服务器的功能,我们可以讨论一下在虚幻引擎 4 中创建服务器的两种不同方式。

专用服务器

专用服务器仅运行服务器逻辑,因此您不会看到游戏运行时的典型窗口,在该窗口中,您作为本地玩家控制角色。此外,如果您使用-log命令提示符运行专用服务器,您将有一个控制台窗口,记录服务器上发生的相关信息,例如客户端是否已连接或断开连接等。作为开发人员,您也可以使用UE_LOG宏记录自己的信息。

使用专用服务器是为多人游戏创建服务器的一种非常常见的方式,由于它比监听服务器(下一节中介绍的更轻量级),您可以将其托管在服务器堆栈上,并保持其运行。

要在虚幻引擎 4 中启动专用服务器,可以使用以下命令参数:

  • Run the following command to start a dedicated server inside an editor through a shortcut or Command Prompt:

    <UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe   <UProject Location> <Map Name> -server -game -log

    这里有一个例子:

    C:\Program Files\Epic   Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe   D:\TestProject\TestProject.uproject TestMap -server -game -log
  • A packaged project requires a special build of the project built specifically to serve as a dedicated server.

    注意

    您可以通过访问https://allars blog . com/2015/11/06/support-special-servers/https://www . ue4 community . wiki/special _ Server _ Guide _(Windows)了解更多关于设置打包专用服务器的信息。

监听服务器

监听服务器同时充当服务器和客户端,因此您也将有一个窗口,在这里您可以作为客户端使用这种服务器类型玩游戏。它还有一个优势,那就是它是让服务器运行的最快方式,但是它没有专用服务器那么轻量级,所以可以同时连接的客户端数量会受到限制。

要启动侦听服务器,可以使用以下命令参数:

  • Run the following command to start a dedicated server inside an editor through a shortcut or Command Prompt:

    <UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe   <UProject Location> <Map Name>?Listen -game

    这里有一个例子:

    C:\Program Files\Epic   Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe   D:\TestProject\TestProject.uproject TestMap?Listen -game
  • A packaged project (development builds only) requires a special build of the project built specifically to serve as a dedicated server:

    <Project Name>.exe <Map Name>?Listen -game

    这里有一个例子:

    D:\Packaged\TestProject\TestProject.exe TestMap?Listen –game

在下一节中,我们将讨论客户。

客户

客户端是架构中最简单的部分,因为大多数参与者在服务器上都有权限,所以在这些情况下,工作将在服务器上完成,客户端只需服从它的命令。

以下是客户主要职责的概述:

  1. 从服务器强制执行变量复制:服务器通常对客户端知道的所有参与者拥有权限,因此当服务器上复制变量的值发生变化时,客户端也需要强制执行该值。

  2. 处理来自服务器的 RPC:客户端需要处理从服务器发送的远程过程调用(包含在第 17 章远程过程调用中)。

  3. 模拟时预测运动:当客户端模拟一个演员时(将在本章后面的中介绍),它需要根据演员的速度本地预测它将会在哪里。

  4. Spawning the actors that only a client needs to know about: If you want to spawn an actor that only exists on a client, then you need to do that on that specific client.

    这是生成参与者的最不常见的方式,因为很少有希望参与者只存在于客户端的情况。这方面的一个例子是你在多人生存游戏中看到的放置预览演员,玩家控制一面半透明版本的墙,其他玩家在实际放置之前看不到它。

客户端可以通过不同的方式加入服务器。以下是最常见的方法列表:

  • Using the Unreal Engine 4 console (by default is the ` key) to open it and type:

    Open <Server IP Address>

    例如:

    Open 194.56.23.4
  • Using the Execute Console Command Blueprint node. An example is as follows:

    Figure 16.3: Joining a server with an example IP with the Execute Console Command node

图 16.3:使用执行控制台命令节点加入一个带有示例 IP 的服务器

  • Using the ConsoleCommand function in APlayerController as follows:

    PlayerController->ConsoleCommand("Open <Server IP Address>");

    这里有一个例子:

    PlayerController->ConsoleCommand("Open 194.56.23.4");
  • Using the editor executable through a shortcut or Command Prompt:

    <UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe   <UProject Location> <Server IP Address> -game

    这里有一个例子:

    C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject 194.56.23.4 -game

  • Using a packaged development build through a shortcut or Command Prompt:

    <Project Name>.exe  <Server IP Address>

    这里有一个例子:

    D:\Packaged\TestProject\TestProject.exe 194.56.23.4

在下面的练习中,我们将在多人游戏中测试虚幻引擎 4 附带的第三人称模板。

练习 16.01:测试多人游戏中的第三人称模板

在本练习中,我们将创建一个第三人称模板项目,并在多人游戏中使用。

以下步骤将帮助您完成练习。

  1. Create a new Third Person template project using Blueprints called TestMultiplayer and save it to a location of your choosing.

    一旦创建了项目,它就应该打开编辑器。我们现在将在多人游戏中测试这个项目,看看它的表现:

  2. 在编辑器中,Play按钮的右侧,有一个箭头指向下方的选项。点击它,你会看到一个选项列表。在Multiplayer Options部分,您可以配置您想要使用多少个客户端,以及您是否想要一个专用服务器。

  3. 不勾选Run Dedicated Server,将Number of Players改为3,点击New Editor Window (PIE)

  4. You should see three windows on top of each other representing the three clients:

    Figure 16.4: Launching three client windows with a listen server

    图 16.4:使用监听服务器启动三个客户端窗口

    如您所见,这有点混乱,所以让我们更改窗口的大小。按下键盘上的 Esc 停止播放。

  5. 再次点击Play按钮旁边的向下箭头,选择最后一个选项Advanced Settings

  6. 搜索Game Viewport Settings部分。将New Viewport Resolution更改为640x480并关闭Editor Preferences标签。

  7. Play the game again and you should see the following:

    Figure 16.5: Launching three client windows using a 640x480 resolution with a listen server

图 16.5:使用 640x480 分辨率和监听服务器启动三个客户端窗口

一旦你开始玩,你会注意到窗口的标题栏写着ServerClient 1Client 2。由于您可以在Server窗口中控制一个字符,这意味着我们正在运行一个监听服务器,其中服务器和客户端在同一个窗口中运行。当这种情况发生时,您应该将窗口标题解释为Server + Client 0,而不仅仅是Server,以避免混淆。

完成本练习后,您现在有了一个运行一个服务器和三个客户端的设置(Client 0Client 1Client 2)。

注意

当多个窗口同时运行时,您会注意到一次只能将输入焦点放在一个窗口上。要将焦点转移到另一个窗口,只需按 Shift + F1 即可失去当前输入焦点,然后只需点击您想要关注的新窗口即可。

如果你在其中一个窗口玩游戏,你会注意到你可以移动和跳跃,其他客户端也可以看到。

一切正常的原因是,角色类附带的角色移动组件会自动为您复制位置、旋转和下落状态(用于显示您是否在跳跃)。如果你想添加一个自定义行为,比如一个攻击动画,你不能只告诉客户端在按下一个键的时候在本地播放一个动画,因为这对其他客户端不起作用。这就是为什么你需要服务器,作为一个中介,告诉所有的客户端在一个客户端按键的时候播放动画。

套装版

一旦你完成了这个项目,最好打包它(如前几章所述的*,这样我们就有了一个不使用虚幻引擎编辑器的纯独立版本,它将运行得更快,更轻量级。*

以下步骤将帮助您创建打包版本的练习 16.01测试多人文件中的第三人模板:

  1. 前往File - > Package Project - > Windows - > Windows (64-bit)

  2. 选择一个文件夹来放置打包的构建,并等待它完成。

  3. 转到选中的文件夹,打开里面的WindowsNoEditor文件夹。

  4. 右键点击TestMultiplayer.exe上的,选择Create Shortcut

  5. 重命名新快捷方式Run Server

  6. 右键点击,选择Properties

  7. 在目标上,追加ThirdPersonExampleMap?Listen -server,这将使用ThirdPersonExampleMap创建一个监听服务器。你应该以这个结束:

    "<Path>\WindowsNoEditor\TestMultiplayer.exe"   ThirdPersonExampleMap?Listen -server
  8. 点击OK运行快捷方式。

  9. 你应该得到一个 Windows 防火墙提示,所以允许它。

  10. 让服务器保持运行,回到文件夹,从TestMultiplayer.exe创建另一个快捷方式。

  11. 改名Run Client

  12. 右键点击,选择Properties

  13. 在目标上,附加127.0.0.1,这是您的本地服务器的 IP。你应该以"<Path>\WindowsNoEditor\TestMultiplayer.exe" 127.0.0.1结束。

  14. 点击OK运行快捷方式。

  15. 您现在已连接到侦听服务器,因此可以看到彼此的角色。

  16. 每次点击Run Client快捷方式,都会给服务器增加一个新的客户端,这样就可以让几个客户端在同一台机器上运行。

在下一节中,我们将关注连接和所有权。

联系和所有权

在虚幻引擎中使用多人游戏时,需要理解的一个重要概念是连接。当一个客户端加入一个服务器时,它将获得一个新的玩家控制器,并有一个与之相关的连接。

如果一个参与者没有与服务器的有效连接,那么该参与者将不能执行复制操作,例如变量复制(本章后面的*)或调用 RPC(在第 17 章, 远程过程调用)。*

*如果玩家控制器是唯一拥有连接的参与者,那么这是否意味着它是唯一可以执行复制操作的地方?不,这就是AActor中定义的GetNetConnection功能发挥作用的地方。

在对一个 actor 进行复制操作(比如变量复制或者调用 RPC)时,虚幻框架会通过调用其上的GetNetConnection()函数来获取 actor 的连接。如果连接有效,则复制操作将被处理,如果无效,则不会发生任何事情。GetNetConnection()最常见的实现来自APawnAActor

让我们来看看APawn类是如何实现GetNetConnection()函数的,该函数通常用于字符:

class UNetConnection* APawn::GetNetConnection() const
{
  // if have a controller, it has the net connection
  if ( Controller )
  {
    return Controller->GetNetConnection();
  }
  return Super::GetNetConnection();
}

前面的实现是虚幻引擎 4 源代码的一部分,它将首先检查棋子是否有有效的控制器。如果控制器有效,那么它将使用它的连接。如果控制器无效,那么它将使用GetNetConnection()功能的父实现,在AActor上:

UNetConnection* AActor::GetNetConnection() const
{
  return Owner ? Owner->GetNetConnection() : nullptr;
}

前面的实现也是虚幻引擎 4 源代码的一部分,它将检查参与者是否有有效的所有者。如果有,它会使用所有者的连接;如果没有,它将返回一个无效的连接。那么这个Owner变量是什么呢?每个演员都有一个名为Owner的变量(可以通过调用SetOwner函数来设置其值),该变量显示哪个演员拥有它,因此您可以将其视为父演员。

GetNetConnection()的这个实现中使用所有者的连接将像一个层次结构一样工作。如果在所有者层次结构中向上移动时,它发现某个所有者是播放器控制器或由播放器控制器控制,那么它将具有有效的连接,并且能够处理复制操作。请看下面的例子。

注意

在监听服务器中,由客户端控制的角色的连接总是无效的,因为该客户端已经是服务器的一部分,因此不需要连接。

想象一个武器演员被放在世界上,它只是坐在那里。在这种情况下,武器不会有拥有者,所以如果武器试图做任何复制操作,比如变量复制或调用 RPC,什么都不会发生。

但是,如果客户拿起武器,用角色的值在服务器上调用SetOwner,那么武器现在将有一个有效的连接。这样做的原因是因为武器是一个行动者,所以为了得到它的连接,它会使用GetNetConnection()AActor实现,这个实现会返回它的拥有者的连接。既然业主是客户的性格,那就用APawnGetNetConnection()实现。角色有一个有效的播放器控制器,所以这是函数返回的连接。

这里有一个图表来帮助你理解这个逻辑:

Figure 16.6: Connections and ownership example of a weapon actor

图 16.6:武器角色的连接和所有权示例

让我们理解无效所有者的要素:

  • AWeapon不会覆盖GetNetConnection功能,所以要获取武器的连接,会调用找到的第一个实现,也就是AActor::GetNetConnection
  • AActor::GetNetConnection的实现调用其所有者GetNetConnection。由于没有所有者,连接无效。

有效的所有者包括以下要素:

  • AWeapon不覆盖GetNetConnection函数,所以要得到它的连接,它会调用找到的第一个实现,也就是AActor::GetNetConnection

  • AActor::GetNetConnection的实现调用其所有者GetNetConnection。既然主人是拿起武器的人物,那上面就会叫GetNetConnection

  • ACharacter不覆盖GetNetConnection函数,所以要得到它的连接,它会调用找到的第一个实现,也就是APawn::GetNetConnection

  • The implementation of APawn::GetNetConnection uses the connection from the owning player controller. Since the owning player controller is valid, then it will use that connection for the weapon.

    注意

    为了使SetOwner按预期工作,它需要在授权上执行,在大多数情况下,授权意味着服务器。如果只在客户端执行SetOwner,它仍然无法执行复制操作。

角色

当您在服务器上生成一个执行元时,将在服务器上创建一个执行元版本,在每个客户端上创建一个执行元版本。由于同一个演员在游戏的不同实例上有不同的版本(ServerClient 1Client 2等等),所以知道哪个版本的演员是哪个很重要。这将允许我们知道在这些实例中可以执行什么逻辑。

为了帮助解决这种情况,每个参与者都有以下两个变量:

  • 本地角色:演员在当前游戏实例中的角色。例如,如果角色是在服务器上产生的,而当前的游戏实例也是服务器,那么这个版本的角色就有权限,所以你可以在上面运行更关键的游戏逻辑。通过调用GetLocalRole()函数来访问。
  • 远程角色:演员在远程游戏实例上的角色。例如,如果当前的游戏实例是服务器,那么它返回参与者在客户端的角色,反之亦然。通过调用GetRemoteRole()函数来访问。

GetLocalRole()GetRemoteRole()函数的返回类型是ENetRole,这是一个枚举,可以有以下可能的值:

  • ROLE_None:演员没有角色,因为没有被复制。
  • ROLE_SimulatedProxy:当前游戏实例没有对参与者的权限,也没有通过玩家控制器来控制它。这意味着它的运动将通过使用演员速度的最后值来模拟/预测。
  • ROLE_AutonomousProxy:当前游戏实例没有对角色的权限,但是它由玩家控制器控制。这意味着我们可以根据玩家的输入向服务器发送更准确的运动信息,而不仅仅是使用演员速度的最后一个值。
  • ROLE_Authority:当前游戏实例对演员拥有完全的权限。这意味着,如果执行元在服务器上,对执行元的复制变量所做的更改将被视为每个客户端需要通过变量复制强制执行的值。

让我们看看下面的示例代码片段:

ENetRole MyLocalRole = GetLocalRole();
ENetRole MyRemoteRole = GetRemoteRole();
FString String;
if(MyLocalRole == ROLE_Authority)
{
  if(MyRemoteRole == ROLE_AutonomousProxy)
  {
    String = «This version of the actor is the authority and
    it›s being controlled by a player on its client»;
  }
  else if(MyRemoteRole == ROLE_SimulatedProxy)
  {
    String = «This version of the actor is the authority but 
    it›s not being controlled by a player on its client»;
  }
}
else String = "This version of the actor isn't the authority";
GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red, String);

前面的代码片段将本地角色和远程角色的值分别存储到MyLocalRoleMyRemoteRole中。之后,它将在屏幕上打印不同的消息,这取决于该版本的演员是权威还是由客户端的玩家控制。

注意

重要的是要明白,如果一个演员有一个ROLE_Authority的本地角色,并不意味着它在服务器上;这意味着它是在最初产生演员的游戏实例上,因此对其拥有权限。

如果一个客户端产生了一个演员,即使服务器和其他客户端不知道,它的本地角色仍然是ROLE_Authority。多人游戏中的大部分演员将由服务器产生;这就是为什么很容易误解权威总是指服务器。

以下表格有助于您理解演员在不同场景中的角色:

Figure 16.7: Roles that an actor can have in different scenarios

图 16.7:演员在不同场景中可以扮演的角色

在上表中,您可以看到演员在不同场景中的角色。

让我们分析每个场景,并解释为什么演员有这个角色:

服务器上产生的演员

演员在服务器上繁衍,所以服务器版本的那个演员会有ROLE_Authority的本地角色和ROLE_SimulatedProxy的远程角色,也就是客户端版本的演员的本地角色。对于客户端版本的演员,其本地角色将是ROLE_SimulatedProxy,远程角色将是ROLE_Authority,这是服务器演员版本的本地角色。

客户端产生的演员

演员是在客户端上衍生出来的,所以客户端版本的那个演员会有ROLE_Authority的本地角色和ROLE_SimulatedProxy的远程角色。由于该执行元没有在服务器上产生,因此它将只存在于产生它的客户端上,因此在服务器和其他客户端上不会有该执行元的版本。

服务器上产生的玩家拥有的棋子

棋子是在服务器上产生的,因此服务器版本的棋子将具有本地角色ROLE_Authority和远程角色ROLE_AutonomousProxy,后者是客户端版本棋子的本地角色。对于客户端版本的棋子,其本地角色将是ROLE_AutonomousProxy,因为它由PlayerController和远程角色ROLE_Authority控制,后者是服务器棋子版本的本地角色。

客户端上产生的玩家拥有的棋子

棋子是在客户端上产生的,因此客户端版本的棋子将具有本地角色ROLE_Authority和远程角色ROLE_SimulatedProxy。因为棋子没有在服务器上产生,所以它将只存在于产生它的客户机上,所以在服务器和其他客户机上不会有这个棋子的版本。

练习 16.02:实现所有权和角色

在本练习中,我们将创建一个使用第三人称模板作为基础的 C++ 项目。

创建一个名为OwnershipTestActor的新参与者,它有一个静态网格组件作为根组件,在每一个勾号上,它将执行以下操作:

  • 在权限上,它将检查在某个半径内哪个字符最接近它(由名为OwnershipRadiusEditAnywhere变量配置),并将该字符设置为其所有者。当半径内无人物时,则拥有者为nullptr
  • 显示其本地角色、远程角色、所有者和连接。
  • 编辑OwnershipRolesCharacter并覆盖Tick功能,使其显示本地角色、远程角色、所有者和连接。
  • 创建一个名为OwnershipRoles.h的新头文件,该文件包含ROLE_TO_String宏,该宏将ENetRole转换为Fstring变量。

以下步骤将帮助您完成练习:

  1. 使用名为OwnershipRolesC++ 创建一个新的Third Person模板项目,并将其保存到您选择的位置。

  2. 一旦创建了项目,它就应该打开编辑器和 Visual Studio 解决方案。

  3. 使用编辑器,创建一个名为OwnershipTestActor的新 C++ 类,该类从Actor派生。

  4. 一旦编译完成,Visual Studio 应该会弹出新创建的.h.cpp文件。

  5. 关闭编辑器并返回到 Visual Studio。

  6. In Visual Studio, open the OwnershipRoles.h file and add the following macro:

    #define ROLE_TO_STRING(Value) FindObject<UEnum>(ANY_PACKAGE,   TEXT("ENetRole"), true)->GetNameStringByIndex((int32)Value)

    这个宏将把我们从GetLocalRole()函数和GetRemoteRole()得到的ENetRole枚举转换成FString。它的工作方式是通过虚幻引擎的反射系统找到ENetRole枚举类型,然后将Value参数转换成FString变量,这样就可以在屏幕上打印出来。

  7. 现在,打开OwnershipTestActor.h文件。

  8. Declare the protected variables for the static mesh component and the ownership radius as shown in the following code snippet:

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category =   "Ownership Test Actor")
    UStaticMeshComponent* Mesh;
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Ownership   Test Actor")
    float OwnershipRadius = 400.0f;

    在前面的代码片段中,我们声明了静态网格组件和OwnershipRadius变量,这允许您配置所有权的半径。

  9. 接下来,删除BeginPlay的声明,将构造函数和Tick函数声明移到保护区。

  10. Now, open the OwnershipTestActor.cpp file and add the required header files as mentioned in the following code snippet:

```cpp
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
#include "OwnershipRolesCharacter.h"
#include "Components/StaticMeshComponent.h"
#include "Kismet/GameplayStatics.h"
```

在前面的代码片段中,我们包含了`DrawDebugHelpers.h`,因为我们将调用`DrawDebugSphere`和`DrawDebugString`函数。我们包括`OwnershipRoles.h`、`OwnershipRolesCharacter.h`和`StaticMeshComponent.h`,以便`.cpp`文件了解这些类。我们最后包含`GameplayStatics.h`,因为我们将调用`GetAllActorsOfClass`函数。
  1. 在构造函数定义中,创建静态网格组件,并将其设置为根组件:
```cpp
Mesh = CreateDefaultSubobject<UStaticMeshComponent>("Mesh");
RootComponent = Mesh;
```
  1. 仍然在构造函数中,将bReplicates设置为true来告诉虚幻引擎,该参与者复制并且应该存在于所有客户端中:
```cpp
bReplicates = true;
```
  1. 删除BeginPlay功能定义。
  2. Tick函数中,绘制一个调试球来帮助可视化所有权半径,如下面的代码片段所示:
```cpp
DrawDebugSphere(GetWorld(), GetActorLocation(), OwnershipRadius,   32, FColor::Yellow);
```
  1. 仍然在Tick功能中,创建将在所有权半径内获得最近的AOwnershipRolesCharacter的权限特定逻辑,如果与当前不同,则将其设置为所有者:
```cpp
if (HasAuthority())
{
  AActor* NextOwner = nullptr;
  float MinDistance = OwnershipRadius;
  TArray<AActor*> Actors;
  UGameplayStatics::GetAllActorsOfClass(this,    AOwnershipRolesCharacter::StaticClass(), Actors);
  for (AActor* Actor : Actors)
  {
const float Distance = GetDistanceTo(Actor);
    if (Distance <= MinDistance)
    {
      MinDistance = Distance;
      NextOwner = Actor;
    }
  }
  if (GetOwner() != NextOwner)
  {
    SetOwner(NextOwner);
  }
}
```
  1. 仍然在Tick函数中,转换本地/远程角色的值(使用我们之前创建的ROLE_TO_STRING宏)、当前所有者以及与字符串的连接:
```cpp
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()-  >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ?   TEXT("Valid Connection") : TEXT("Invalid Connection");
```
  1. To finalize the Tick function, use DrawDebugString to display onscreen the strings we converted in the previous step:
```cpp
const FString Values = FString::Printf(TEXT("LocalRole =   %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"),   *LocalRoleString, *RemoteRoleString, *OwnerString,   *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr,   FColor::White, 0.0f, true);
```

注意

您可以使用`AActor`中定义的`HasAuthority()`助手函数,而不是不断使用`GetLocalRole() == ROLE_Authority`来检查参与者是否有权限。
  1. 接下来,打开OwnershipRolesCharacter.h并将Tick功能声明为受保护:
```cpp
virtual void Tick(float DeltaTime) override;
```
  1. 现在,打开OwnershipRolesCharacter.cpp并包含头文件,如下面的代码片段所示:
```cpp
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
```
  1. 实现Tick功能:
```cpp
void AOwnershipRolesCharacter::Tick(float DeltaTime)
{
  Super::Tick(DeltaTime);
}
```
  1. 将本地/远程角色的值(使用我们之前创建的ROLE_TO_STRING宏)、当前所有者和连接转换为字符串:
```cpp
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()-  >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ?   TEXT("Valid Connection") : TEXT("Invalid Connection");
```
  1. Use DrawDebugString to display onscreen the strings we converted in the previous step:
```cpp
const FString Values = FString::Printf(TEXT("LocalRole =   %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleString, *RemoteRoleString, *OwnerString,   *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr,   FColor::White, 0.0f, true);
```

最后,我们可以测试这个项目。
  1. 运行代码,等待编辑器完全加载。
  2. 在从OwnershipTestActor派生的Content文件夹中创建一个名为OwnershipTestActor_BP的新蓝图。设置Mesh使用立方体网格,并在世界上放置它的一个实例。
  3. 转到Multiplayer Options,将客户端数量设置为2
  4. 将窗口大小设置为800x600
  5. Play using New Editor Window (PIE).
您应该会得到以下输出:

Figure 16.8: Expected result on the server and Client 1 window

图 16.8:服务器和客户端 1 窗口的预期结果

通过完成本练习,您将更好地了解连接和所有权是如何工作的。这些都是需要了解的重要概念,因为与复制相关的一切都依赖于它们。

下次当您看到一个参与者没有执行复制操作时,您将知道您需要首先检查它是否有一个有效连接和一个所有者

现在,让我们分析服务器和客户端窗口中显示的值。

服务器窗口

看看上一个练习中Server窗口的输出截图:

Figure 16.9: The Server window

图 16.9:服务器窗口

注意

上面写着Server CharacterClient 1 CharacterOwnership Test Actor的文字不是原截图的一部分,添加是为了帮助大家理解哪个角色哪个演员。

在前面的截图中,可以看到Server CharacterClient 1 Character,以及Ownership Test立方体的演员。

我们先来分析一下Server Character的数值。

服务器通道特征

这是监听服务器正在控制的角色。与该字符相关的值如下:

  • LocalRole = ROLE_Authority:因为这个角色是在服务器上衍生出来的,是当前的游戏实例。
  • RemoteRole = ROLE_SimulatedProxy:因为这个角色是服务器上衍生出来的,所以其他客户端应该只模拟它。
  • Owner = PlayerController_0:因为这个角色是由监听服务器的客户端控制的,监听服务器使用第一个PlayerController实例PlayerController_0
  • Connection = Invalid Connection:因为我们是监听服务器的客户端,所以不需要连接。

接下来,我们将在同一个窗口中查看Client 1 Character

客户端 1 字符

这就是Client 1所控制的人物。与该字符相关的值如下:

  • LocalRole = ROLE_Authority:因为这个角色是在服务器上衍生出来的,是当前的游戏实例。
  • RemoteRole = ROLE_AutonomousProxy:因为这个角色是在服务器上产生的,但是被另一个客户端控制了。
  • Owner = PlayerController_1:因为这个角色正在被另一个客户端控制,这个客户端使用了第二个PlayerController实例PlayerController_1
  • Connection = Valid Connection:因为这个角色正在被另一个客户端控制,所以需要连接到服务器。

接下来,我们将在同一个窗口中观察OwnershipTest演员。

船东试船演员

这是将它的所有者设置为某个所有权半径内最接近的角色的多维数据集执行元。与此参与者关联的值如下:

  • LocalRole = ROLE_Authority:因为这个演员是放在关卡中,在服务器上衍生出来的,是当前的游戏实例。
  • RemoteRole = ROLE_SimulatedProxy:因为这个演员是在服务器中衍生出来的,但是它没有被任何客户端控制。
  • OwnerConnection的值将基于最接近的字符。如果所有权半径内没有角色,则他们将具有No OwnerInvalid Connection的值。

现在,让我们看看Client 1窗口:

Figure 16.10: The Client 1 window

图 1 6.10:客户端 1 窗口

客户端 1 窗口

Client 1窗口的值将与Server窗口完全相同,除了LocalRoleRemoteRole的值将被反转,因为它们总是相对于您所在的游戏实例。

另一个例外是,服务器角色没有所有者,其他连接的客户端没有有效的连接。原因是客户端不存储玩家控制器和其他客户端的连接,只有服务器存储,但这将在第 18 章多人游戏中的游戏框架类中有更深入的介绍。

在下一节中,我们将研究变量复制。

变量复制

服务器保持客户端同步的方法之一是使用变量复制。它的工作方式是,服务器中的变量复制系统每秒每特定次数(在AActor::NetUpdateFrequency变量中为每个参与者定义,该变量也暴露给蓝图)将检查客户端中是否有任何需要用最新值更新的复制变量(下一节中解释的*)。*

如果变量满足所有复制条件,则服务器将向客户端发送更新并强制实施新值。

例如,如果您有一个复制的Health变量,并且客户端使用黑客工具将该变量的值从10设置为100,则复制系统将从服务器强制执行真实值,并将其更改回10,这将使黑客无效。

只有在以下情况下,才会将变量发送到客户端进行更新:

  • 该变量被设置为复制。
  • 服务器上的值已更改。
  • 客户端上的值不同于服务器上的值。
  • 该参与者已启用复制。
  • 参与者是相关的,并且满足所有复制条件。

需要考虑的重要一点是,决定变量是否应该复制的逻辑每秒只执行AActor::NetUpdateFrequency次。换句话说,在您更改服务器上的变量值后,服务器不会立即向客户端发送更新请求。它将仅在变量复制系统执行时发送该请求,这是每秒AActor::NetUpdateFrequency次,并且它已经确定来自客户端的值不同于来自服务器的值。

例如,如果有一个整数复制一个名为Test的变量,该变量的默认值为5。如果您在服务器上调用一个将Test设置为3的函数,并在下一行将其更改为8,那么只有后一个更改会向客户端发送更新请求。原因是这两个变化是在NetUpdateFrequency间隔之间进行的,所以当变量复制系统执行时,当前值是8,由于它不同于客户端的值(仍然是5,所以它会更新它们。如果不将其设置为8,而是将其设置回5,则不会向客户端发送任何更改。

重复变量

在虚幻引擎中,任何可以使用UPROPERTY宏的变量都可以设置为复制,你可以使用两个说明符来实现。

复制

如果你只想说一个变量被复制了,那么你就用Replicated说明符。

请看下面的例子:

UPROPERTY(Replicated) 
float Health = 100.0f; 

在前面的代码片段中,我们声明了一个名为Health的浮点变量,就像我们通常做的那样。不同的是,我们添加了UPROPERTY(Replicated)来告诉虚幻引擎Health变量将被复制。

重新通知

如果你想说一个变量被复制并在每次更新时调用一个函数,那么你可以使用ReplicatedUsing=<Function Name>说明符。请看下面的例子:

UPROPERTY(ReplicatedUsing=OnRep_Health) 
float Health = 100.0f;
UFUNCTION() 
void OnRep_Health()
{
  UpdateHUD(); 
}

在前面的代码片段中,我们声明了一个名为Health的浮点变量。不同的是,我们增加了UPROPERTY(ReplicatedUsing=OnRep_Health)来告诉虚幻引擎这个变量将被复制,并且每次更新它都会调用OnRep_Health函数,在这个特定的例子中,它会调用一个函数来更新HUD

通常,回调函数的命名方案是OnRepNotify_<Variable Name>OnRep_<Variable Name>

注意

ReplicatingUsing说明符中使用的函数需要标记为UFUNCTION()

获取终身复制产品

除了将变量标记为复制之外,您还需要在参与者的cpp文件中实现GetLifetimeReplicatedProps函数。需要考虑的一点是,一旦您至少有一个复制变量,这个函数就在内部声明,所以您不应该在参与者的头文件中声明它。这个函数的目的是告诉你每个复制的变量应该如何复制。您可以通过在每个要复制的变量上使用DOREPLIFETIME宏及其变体来实现这一点。

doreplitime

此宏告诉复制系统,复制的变量(作为参数输入)将复制到所有没有复制条件的客户端。

以下是它的语法:

DOREPLIFETIME(<Class Name>, <Replicated Variable Name>); 

请看下面的例子:

void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray<   FLifetimeProperty >& OutLifetimeProps) const
{
  Super::GetLifetimeReplicatedProps(OutLifetimeProps);
  DOREPLIFETIME(AVariableReplicationActor, Health);
}

在前面的代码片段中,我们使用DOREPLIFETIME宏告诉复制系统,AVariableReplicationActor类中的Health变量将在没有额外条件的情况下复制。

doreplitime _ CONDITION

此宏告诉复制系统,复制的变量(作为参数输入)将只复制到满足条件(作为参数输入)的客户端。

以下是语法:

DOREPLIFETIME_CONDITION(<Class Name>, <Replicated Variable Name>,   <Condition>); 

条件参数可以是以下值之一:

  • COND_InitialOnly:变量只会复制一次,与初始复制一样。
  • COND_OwnerOnly:变量只会复制给行为人的所有者。
  • COND_SkipOwner:变量不会复制给行为人的所有者。
  • COND_SimulatedOnly:变量只会复制给正在模拟的演员。
  • COND_AutonomousOnly:变量只会复制给自主的行动者。
  • COND_SimulatedOrPhysics:该变量将只复制给正在模拟的演员或bRepPhysics设置为真的演员。
  • COND_InitialOrOwner:变量只会复制一次,初始复制还是复制给行为人的所有者。
  • COND_Custom:只有当变量的SetCustomIsActiveOverride布尔条件(用于AActor::PreReplication函数)为真时,该变量才会复制。

请看下面的例子:

void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray<   FLifetimeProperty >& OutLifetimeProps) const
{
  Super::GetLifetimeReplicatedProps(OutLifetimeProps);
  DOREPLIFETIME_CONDITION(AVariableReplicationActor, Health,     COND_OwnerOnly);
}

在前面的代码片段中,我们使用DOREPLIFETIME_CONDITION宏来告诉复制系统,AVariableReplicationActor类中的Health变量将只为该参与者的所有者复制。

注意

还有更多DOREPLIFETIME宏可用,但本书不会涉及。要查看所有变体,请查看虚幻引擎 4 源代码中的UnrealNetwork.h文件。请参见以下网址的说明:

练习 16.03:使用复制、重新通知、doreplifitme 和 DOREPLIFETIME _ CONDITION 复制变量

在本练习中,我们将创建一个 C++ 项目,该项目使用第三人称模板作为基础,并向角色添加两个变量,这两个变量以以下方式复制:

  • 变量A是一个将使用Replicated UPROPERTY说明符和DOREPLIFETIME宏的浮点数。
  • 变量B是一个整数,将使用ReplicatedUsing UPROPERTY说明符和DOREPLIFETIME_CONDITION宏。

以下步骤将帮助您完成练习:

  1. 使用名为VariableReplicationC++ 创建一个新的Third Person模板项目,并将其保存到您选择的位置。

  2. 一旦创建了项目,它就应该打开编辑器和 Visual Studio 解决方案。

  3. 关闭编辑器并返回到 Visual Studio。

  4. 打开VariableReplicationCharacter.h文件。

  5. 接下来,在VariableReplicationCharacter.generated.h之前包含UnrealNetwork.h头文件,它有我们将要使用的DOREPLIFETIME宏的定义:

    #include "Net/UnrealNetwork.h"
  6. 使用各自的复制说明符

    UPROPERTY(Replicated) 
    float A = 100.0f; 
    UPROPERTY(ReplicatedUsing = OnRepNotify_B) 
    int32 B; 

    ,将受保护变量AB声明为UPROPERTY

  7. 宣布Tick功能受保护:

    virtual void Tick(float DeltaTime) override;
  8. 既然我们已经将变量B声明为ReplicatedUsing = OnRepNotify_B,那么我们还需要将受保护的OnRepNotify_B回调函数声明为UFUNCTION :

    UFUNCTION() 
    void OnRepNotify_B(); 
  9. 现在,打开VariableReplicationCharacter.cpp文件,包括标题Engine.h,这样我们就可以使用AddOnScreenDebugMessage功能,以及DrawDebugHelpers.h,这样我们就可以使用DrawDebugString功能:

    #include "Engine/Engine.h"
    #include "DrawDebugHelpers.h"
  10. 实现GetLifetimeReplicatedProps功能:

```cpp
void AVariableReplicationCharacter::GetLifetimeReplicatedProps(TArray<   FLifetimeProperty >& OutLifetimeProps) const 
{
  Super::GetLifetimeReplicatedProps(OutLifetimeProps);
}
```
  1. 将其设置为A变量,该变量将在没有任何额外条件的情况下复制:
```cpp
DOREPLIFETIME(AVariableReplicationCharacter, A);
```
  1. 将其设置为B变量,该变量将只复制给该参与者的所有者:
```cpp
DOREPLIFETIME_CONDITION(AVariableReplicationCharacter, B,   COND_OwnerOnly);
```
  1. 实现Tick功能:
```cpp
void AVariableReplicationCharacter::Tick(float DeltaTime) 
{
  Super::Tick(DeltaTime);
}
```
  1. Next, run the authority-specific logic that adds 1 to A and B:
```cpp
if (HasAuthority()) 
{ 
  A++ ; 
  B++ ; 
} 
```

因为这个字符会在服务器上产生,所以只有服务器会执行这个逻辑。
  1. 在字符位置显示AB的值:
```cpp
const FString Values = FString::Printf(TEXT("A = %.2f    B =   %d"), A, B); 
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr,   FColor::White, 0.0f, true);
```
  1. Implement the RepNotify function for variable B, which displays on the screen a message saying that the B variable was changed to a new value:
```cpp
void AVariableReplicationCharacter::OnRepNotify_B() 
{
  const FString String = FString::Printf(TEXT("B was changed by     the server and is now %d!"), B); 
  GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red,String); 
}
```

最后,您可以测试项目:
  1. 运行代码,等待编辑器完全加载。
  2. 转到Multiplayer Options,将客户端数量设置为2
  3. 将窗口大小设置为800x600
  4. 使用New Editor Window (PIE)播放。

完成本练习后,您将能够在每个客户端上进行游戏,您会注意到角色正在显示各自的AB值。

现在,让我们分析一下ServerClient 1窗口中显示的值。

服务器窗口

Server窗口中,你有Server Character的值,它是由服务器控制的角色,在后台,你有Client 1 Character的值:

Figure 16.11: The Server window

图 16 .11:服务器窗口

可以观察到的输出如下:

  • Server``CharacterA = 674.00 B = 574
  • Client 1``CharacterA = 670.00 B = 570

在这个特定的时间点上,Server Character的值为674代表A``574代表B。之所以AB数值不同,是因为A100开始,B0开始,这是A++ B++ 574刻度之后的正确数值。

至于为什么Client 1 Character和服务器角色没有相同的值,那是因为Client 1是在服务器之后稍微创建的,所以在这种情况下,计数会被A++ B++ 4滴答关闭。

接下来,我们将看到Client 1窗口。

客户端 1 窗口

Client 1窗口中,你有Client 1 Character的值,它是由Client 1控制的角色,在后台,你有Server Character的值:

Figure 16.12: The Client 1 window

图 16.12:客户端 1 窗口

可以观察到的输出如下:

  • Server``CharacterA = 674.00 B = 0
  • Client 1``CharacterA = 670.00 B = 570

Client 1 Character具有来自服务器的正确值,因此变量复制正在按预期工作。如果看Server CharacterA就是674,没错,但是B就是0。原因是A使用的是DOREPLIFETIME,没有添加任何额外的复制条件,所以每次服务器上的变量发生变化,它都会复制变量,让客户端保持最新。

另一方面,变量BDOREPLIFETIME_CONDITIONCOND_OwnerOnly一起使用,由于Client 1不是拥有Server Character的客户端(监听服务器的客户端是),因此该值不会被复制,并且与0的默认值保持不变。

如果你回到代码,将B的复制条件改为使用COND_SimulatedOnly而不是COND_OwnerOnly,你会注意到结果会在Client 1 window反转。B的价值会为Server Character复制,但不会为自己的性格复制。

注意

之所以在Server窗口而不是客户端窗口显示RepNotify消息,是因为在编辑器中播放时,两个窗口共享相同的过程,因此在屏幕上打印文本不会准确。为了获得正确的行为,您需要运行游戏的打包版本。

2D 混合空间

第 2 章使用虚幻引擎中,我们创建了一个 1D 混合空间,根据速度轴的值在角色的运动状态(空闲、行走和奔跑)之间进行混合。对于那个特定的例子,它工作得非常好,因为你只需要一个轴,但是如果我们希望角色也能够扫射,那么我们就不能真正做到这一点。

为了探索这种情况,虚幻引擎允许您创建 2D 混合空间。概念几乎完全相同;唯一的区别是你有一个额外的动画轴,所以你不仅可以在水平方向上混合,也可以在垂直方向上混合。

练习 16.04:创建运动 2D 混合空间

在本练习中,我们将创建一个使用两个轴而不是一个轴的混合空间。纵轴为Speed,在0800之间。横轴为Direction,代表棋子的速度和旋转/前进矢量之间的相对角度(-180 to 180)。

下图将帮助您计算本练习中的方向:

Figure 16.13: Direction values based on the angle between the forward  vector and the velocity

图 16.13:基于前向矢量和速度之间角度的方向值

在上图中,您可以看到如何计算方向。前向矢量表示角色当前面对的方向,数字表示如果前向矢量指向该方向,它将与速度矢量形成的角度。如果角色朝某个方向看,并且你按了一个键将角色向右移动,那么速度向量将垂直于向前的向量。这意味着角度是 90 度,这就是我们的方向。

如果我们按照这个逻辑设置我们的 2D 混合空间,我们可以根据角色的移动角度使用正确的动画。

以下步骤将帮助您完成练习:

  1. 使用名为Blendspace2DBlueprints创建一个新的Third Person模板项目,并将其保存到您选择的位置。
  2. 一旦创建了项目,它就应该打开编辑器。
  3. 接下来,您将导入运动动画。在编辑器中,转到Content\Mannequin\Animations文件夹。
  4. 点击Import按钮。
  5. 进入Chapter16\Exercise16.04\Assets文件夹,选择所有fbx文件,点击Open按钮。
  6. 在导入对话框中,确保选择角色的骨骼并点击Import All按钮。
  7. 将所有新文件保存在Assets文件夹中。
  8. 点击Add New按钮,选择Animation -> Blend Space
  9. 接下来,选择角色的骨骼。
  10. 重命名混合空间BS_Movement并将其打开。
  11. Create the horizontal Direction axis (-180 to 180) and the vertical Speed axis (0 to 800) as shown in the following figure:
![Figure 16.14: 2D Blend Space Axis Settings ](img/B16183_16_14.jpg)

图 16.14: 2D 混合空间轴设置
  1. Idle_Rifle_Ironsights动画拖到5网格条目上,其中Speed0
  2. 拖动Walk_Fwd_Rifle_Ironsights动画,其中Speed800Direction0
  3. 拖动Walk_Lt_Rifle_Ironsights动画,其中Speed800Direction-90
  4. Drag the Walk_Rt_Rifle_Ironsights animation where Speed is 800 and Direction is 90.
您应该会得到一个混合空间,可以通过按住*移动*并移动鼠标来预览。
  1. 现在,在Asset Details面板上,将Target Weight Interpolation Speed Per Sec变量设置为5,使插值更加平滑。
  2. 保存并关闭混合空间。
  3. 现在,更新动画蓝图以使用新的混合空间。
  4. 转到Content\Mannequin\Animations并打开第三人称模板附带的文件–ThirdPerson_AnimBP
  5. 接下来,转到事件图,创建一个名为Direction的新浮点变量。
  6. Set the value of Direction with the result of the Calculate Direction function, which calculates the angle (-180º to 180º) between the pawn's velocity and rotation:
![Figure 16.15: Calculating the Speed and Direction to use on the 2D Blend Space ](img/B16183_16_15.jpg)

图 16.15:计算用于 2D 混合空间的速度和方向

注意

您可以在以下链接找到前面的全分辨率截图,以便更好地查看:[https://packt.live/3pAbbAl](https://packt.live/3pAbbAl)。
  1. In AnimGraph, go to the Idle/Run state where the old 1D Blend Space is being used, as shown in the following screenshot:
![Figure 16.16: Idle/run state in the AnimGraph ](img/B16183_16_16.jpg)

图 16.16:动画中的空闲/运行状态
  1. Replace that Blend Space with BS_Movement and use the Direction variable like so:
![Figure 16.17: 1D Blend Space has been replaced by the new 2D Blend Space ](img/B16183_16_17.jpg)

图 16.17: 1D 混合空间已被新的 2D 混合空间取代
  1. 保存并关闭动画蓝图。现在你需要更新角色。
  2. 转到Content\ThirdPersonBP\Blueprints文件夹,打开ThirdPersonCharacter
  3. 在角色的Details面板上,将Use Controller Rotation Yaw设置为true,这将使角色的Yaw旋转始终面向控制旋转的偏航。
  4. 转到角色移动组件,将Max Walk Speed设置为800
  5. Orient Rotation to Movement设置为false,这将防止角色向运动方向旋转。
  6. 保存并关闭角色蓝图。

如果你现在用两个客户端玩游戏,移动角色,它会前后走动,但也会扫射,如下图截图所示:

Figure 16.18: Expected output on the server and Client 1 windows

图 16.18:服务器和客户端 1 窗口上的预期输出

通过完成本练习,您将更好地理解如何创建 2D 混合空间,它们是如何工作的,以及与仅使用常规 1D 混合空间相比它们所提供的优势。

在下一节中,我们将研究如何变换角色的骨骼,以便我们可以根据相机的俯仰来上下旋转玩家的躯干。

变换(修改)骨骼

在我们继续之前,有一个非常有用的节点可以在动画中使用,叫做Transform (Modify) Bone节点,它允许你在运行时平移、旋转和缩放骨骼。

您可以在AnimGraph中添加它,方法是在空白区域用鼠标右键单击,键入transform modify,然后从列表中选择该节点。如果你点击Transform (Modify) Bone节点,你会在Details面板上有很多选项。

这里解释了每个选项的作用。

  • The Bone to Modify option will tell the node what bone is going to be transformed.

    在该选项之后,有三个部分代表每个变换操作(TranslationRotationScale)。在每个部分中,您可以执行以下操作:

  • Translation, Rotation, Scale: This option will tell the node how much of that specific transform operation you want to apply. The final result will depend on the mode (covered in the next section) you have selected.

    有两种方法可以设置该值:

  • 设置一个恒定值,如(X=0.0,Y=0.0,Z=0.0)

  • 使用变量,因此它可以在运行时更改。要启用此功能,您需要采取以下步骤(此示例针对Rotation,但相同的概念适用于TranslationScale):

  1. Click the checkbox next to the constant value and make sure it is checked. Once you do that, the text boxes for the constant value will disappear.

    Figure 16.19: Check the checkbox

图 16.19:选中复选框

Transform (Modify) Bone将添加一个输入,以便您可以插入您的变量:

Figure 16.20: Variable used as an input on the Transform (Modify) Bone node

图 16.20:用作变换(修改)骨骼节点输入的变量

设置模式

这将告诉节点如何处理该值。您可以从以下三个选项中选择一个:

  • Ignore:不要用提供的值做任何事情。

  • Add to Existing:抓取骨骼的当前值,并将提供的值加入其中。

  • Replace Existing: Replace the current value of the bone with the supplied value.

    设置空间

    这将定义节点应用转换的空间。您可以从以下四个选项中选择一个:

  • World Space:变换会发生在世界空间。

  • Component Space:变换将发生在骨骼网格组件空间。

  • Parent Bone Space:变换将发生在所选骨骼的父骨骼空间。

  • Bone Space:变换会发生在选中骨骼的空间。

最后但同样重要的是,您有Alpha,这是一个允许您控制要应用的变换量的值。例如,如果您将Alpha值作为一个浮点值,那么您将有以下不同值的行为:

  • 如果Alpha为 0.0,则不应用任何变换。
  • 如果Alpha是 0.5,那么它只会应用一半的变换。
  • 如果Alpha为 1.0,则应用整个变换。

在下一个练习中,我们将使用Transform (Modify) Bone节点来启用来自练习 16.04创建运动 2D 混合空间的角色,以基于相机的旋转上下查看。

练习 16.05:创建一个上下看的角色

在本练习中,我们将复制来自练习 16.04的项目,创建一个运动 2D 混合空间,并使角色能够基于相机的旋转上下查看。为了实现这一点,我们将使用Transform (Modify) Bone节点,根据相机的音高旋转组件空间中的spine_03骨骼。

以下步骤将帮助您完成练习:

  1. 首先,您需要从练习 16.04复制并重命名项目,创建运动 2D 混合空间

  2. 练习 16.04复制Blendspace2D项目文件夹,创建运动 2D 混合空间,将其粘贴到新文件夹中,并将其重命名为TransformModifyBone

  3. Open the new project folder, rename the Blendspace2D.uproject file TransformModifyBone.uproject, and open it.

    接下来,您将更新动画蓝图。

  4. 转到Content\Mannequin\Animations打开ThirdPerson_AnimBP

  5. Go to the Event Graph, create a float variable called Pitch, and set it with the Pitch of the subtraction (or delta) between the pawn's rotation and the base aim rotation, as shown in the following figure:

    Figure 16.21: Calculating the Pitch

    图 16.21:计算间距

    作为使用Break Rotator节点的替代方法,您可以在Return Value上的上单击鼠标右键*,然后选择Split Struct Pin。*

    注意

    Break Rotator节点允许您将一个Rotator变量分成三个浮动变量,分别代表PitchYawRoll。当您想要访问每个单独组件的值时,或者如果您只想处理一个或两个组件,而不想处理整个旋转时,这非常有用。

    考虑到Split Struct Pin选项只有在Return Value没有连接到任何东西时才会出现。一旦您进行了拆分,它将为RollPitchYaw创建三条单独的线,就像断开一样,但没有额外的节点。

    您应该会得到以下结果:

    Figure 16.22: Calculating the Pitch to look up using the Split Struct Pin option

    图 16.22:使用拆分结构引脚选项计算要查找的间距

    这个逻辑利用棋子的旋转,从摄像头的旋转中减去,得到Pitch中的差值,如下图所示:

    Figure 16.23: How to calculate the Delta Pitch

    图 16.23:如何计算增量间距

  6. Next, go to AnimGraph and add a Transform (Modify) Bone node with the following settings:

    Figure 16.24: Settings for the Transform (Modify) Bone node

    图 16.24:变换(修改)骨骼节点的设置

    在前面的截图中,我们已经将Bone to Modify设置为spine_03,因为那是我们想要旋转的骨骼。我们还将Rotation Mode设置为Add to Existing,因为我们希望保留动画中的原始旋转,并为其添加偏移。其余选项需要有默认值。

  7. Connect the Transform (Modify) Bone node to the State Machine and the Output Pose, as shown in the following screenshot:

    Figure 16.25: Transform (Modify) Bone connected to the Output Pose

图 16.25:变换(修改)连接到输出姿势的骨骼

在上图中,您看到了完整的AnimGraph,这将允许角色通过基于相机间距旋转spine_03骨骼来上下查看。State Machine将是起点,从那里需要转换成组件空间,以便能够使用Transform (Modify) Bone节点,该节点在转换回本地空间后将连接到Output Pose节点。

注意

我们将Pitch变量连接到Roll的原因是骨骼中的骨骼是这样内部旋转的。您也可以在输入参数上使用Split Struct Pin,因此您不必添加Make Rotator节点。

如果你用两个客户端测试项目,将鼠标上移下移到其中一个角色上,你会注意到它会上下俯仰,如下图截图所示:

Figure 16.26: Character mesh pitching up and down, based on the camera rotation

图 16.26:基于相机旋转的角色网格上下俯仰

通过完成最后的练习,您将了解如何使用动画蓝图中的Transform (Modify) Bone节点在运行时修改骨骼。这个节点可以在各种场景中使用,因此它可能对您非常有用。

在下一个活动中,您将通过创建我们将用于多人 FPS 项目的角色来测试所学的一切。

活动 16.01:为多人 FPS 项目创建角色

在本活动中,您将为我们将在接下来几章中构建的多人 FPS 项目创建角色。角色会有一些不同的机制,但是对于这个活动,你只需要创建一个可以行走、跳跃、上下看的角色,并且有两个复制的属性:生命值和护甲。

以下步骤将帮助您完成活动:

  1. 创建一个名为MultiplayerFPSBlank C++ 项目,不包含起始内容。

  2. Activity16.01\Assets folder导入骨骼网格和动画,并将它们分别放置在Content\Player\MeshContent\Player\Animations文件夹中。

  3. 将以下声音从Activity16.01\Assets文件夹导入Content\Player\Sounds:

    • Jump.wav:用Play Sound动画通知在Jump_From_Stand_Ironsights动画上播放这个声音。
    • Footstep.wav:使用Play Sound anim notify,在每次行走动画中,每次脚踩地板时播放此声音。
    • Spawn.wav:在字符中的SpawnSound变量上使用。
  4. 通过重新定位骨骼并创建一个名为Camera的插座来设置骨骼网格,该插座是头部骨骼的子节点,其相对位置为(X=7.88, Y=4.73, Z=-10.00)。

  5. 在名为BS_MovementContent\Player\Animations中创建一个 2D 混合空间,使用导入的运动动画和5Target Weight Interpolation Speed Per Sec

  6. 使用在第 4 章玩家输入中获得的知识,在Project Settings中创建输入映射:

    • 跳转(动作映射)–空格键
    • 向前移动(轴映射)–W(刻度1.0)和 S (刻度-1.0)
    • 向右移动(轴映射)–A(刻度-1.0)和 D (刻度1.0)
    • 旋转(轴映射)–鼠标 X (缩放1.0)
    • 向上看(轴映射)–鼠标 Y (缩放-1.0)
  7. 创建一个名为FPSCharacter的 C++ 类,它执行以下操作:

    • 源自Character类。

    • Camera插座上有一个连接到骨骼网格的相机组件,并将pawn control rotation设置为true

    • 对于healtharmor有只复制给所有者的变量。

    • 有最大healtharmor的变量,以及护甲吸收伤害的百分比。

    • 有一个构造器,用于初始化摄像机,禁用嘀嗒声,并将Max Walk Speed设置为800,将Jump Z Velocity设置为600

    • BeginPlay上,播放产卵声音,有权限的话用max health初始化health

    • 创建并绑定函数来处理输入操作和轴。

    • 具有添加/删除/设置健康的功能。这也保证了角色死亡的情况。

    • Has functions to add/set/absorb armor. The armor absorption reduces the armor based on the ArmorAbsorption variable and changes the damage value based on the formula:

      伤害=(伤害 (1 -装甲吸收))-FMath::Min(remaininggarmor,0);*

  8. Content\Player\Animations中创建一个名为ABP_Player的动画蓝图,它有一个带有以下状态的State Machine:

    • Idle/Run:将BS_MovementSpeedDirection变量一起使用

    • Jump: Plays the jump animation and transitions from the Idle/Run states when the Is Jumping variable is true

      它还使用Transform (Modify) Bone根据相机的音高使角色上下俯仰。

  9. 使用在第 15 章收藏品、电源和皮卡中获得的知识,在Content\UI中创建一个名为UI_HUDUMG小部件,以Health: 100Armor: 100的格式显示人物的HealthArmor

  10. Content\Player中创建一个从FPSCharacter派生的名为BP_Player的蓝图,并将网格组件设置为具有以下值: * 使用SK_Mannequin骨骼网格 * 使用ABP_Player动画蓝图 * 设置Location等于( X=0.0,Y=0.0,Z=-88.0 ) * Set Rotation to be equal to (X=0.0, Y=0.0, Z=-90.0)

    同样,在Begin Play事件上,它需要创建UI_HUD的小部件实例,并将其添加到视口中。

  11. Content\Blueprints中创建一个从MultiplayerFPSGameModeBase派生的名为BP_GameMode的蓝图,它将使用BP_Player作为DefaultPawn类。

  12. Content\Maps中创建一个名为DM-Test的测试地图,并将其设置为Project Settings中的默认地图。

预期产出:

结果应该是一个项目,其中每个客户将有一个第一人称角色,可以移动,跳跃,并环顾四周。这些动作也将被复制,因此每个客户端将能够看到另一个客户端的角色正在做什么。

每个客户端还会有一个显示生命值和护甲值的平视显示器。

Figure 16.27: Expected output

图 16.27:预期产出

注意

这个活动的解决方案可以在:https://packt.live/338jEBx找到。

最终的结果应该是两个可以看到彼此移动、跳跃和四处张望的角色。每个客户端还显示其角色的生命值和护甲值。

通过完成本练习,您应该对服务器-客户端体系结构、变量复制、角色、2D 混合空间和Transform (Modify) Bone节点的工作方式有了一个很好的了解。

总结

在本章中,我们了解了一些关键的多人游戏概念,例如服务器-客户端架构如何工作、服务器和客户端的职责、监听服务器如何比专用服务器更快地设置,但不像轻量级服务器那样快、所有权和连接、角色和变量复制。

我们还学习了一些有用的动画技术,例如如何使用 2D 混合空间,它允许您有一个双轴网格在动画之间混合,以及变换(修改)骨骼节点,它能够在运行时修改骨骼网格的骨骼。为了完成这一章,我们创建了一个第一人称多人游戏项目,在这个项目中,你可以看到可以行走、看和跳跃的角色,这将是我们在接下来几章中将要研究的多人第一人称射击游戏项目的基础。

在下一章中,我们将学习如何使用 RPC,它允许客户端和服务器在彼此上执行功能。我们还将介绍如何在编辑器中使用枚举,以及如何使用双向循环数组索引,这允许您在数组中前后循环,当超出限制时循环返回。*