Azure PlayFab 上的 Unity 第 3 部分:设置多人服务器(第 1 部分)





5.00/5 (1投票)
在本篇文章中,我们将开始创建游戏将连接以进行多人游戏后端代码。
在本篇文章中,我们将学习从上一篇文章创建一个多人游戏服务器进程并连接到它。我们将从我们的 Unity 游戏项目中构建一个多人游戏服务器,并添加脚本代码来启动和处理多人游戏事件,例如玩家加入和离开。然后,我们将把项目构建成一个无 GUI 的仅服务器的可执行文件。我们将在文章结尾更新我们的项目,使其充当客户端应用程序并连接到服务器。
让我们继续从上一篇文章中的 Unity 游戏项目,并将其转变为我们的游戏可以连接以进行多人游戏匹配的服务器代码。
要求
要跟随本教程,您需要一个PlayFab 账户和以下软件
- Unity 游戏引擎
- Microsoft Visual Studio
- 来自上一篇文章的 Unity游戏项目
任务简报
在开始之前,让我们回顾一下我们将使用 Unity 项目做什么。这将确保后续步骤清晰,并且更容易理解如何将许多相同步骤应用于任何其他 Unity 项目以添加 PlayFab 功能。
在本篇文章中,我们将使用相同的 Unity 项目构建两个基于项目设置的可执行文件:一个后端服务器,它在没有图形的情况下运行并可以托管多人游戏会话;以及每个玩家都可以用来连接到服务器程序的客户端游戏。我们将实现一个基本的多人游戏服务器游戏逻辑,该逻辑不考虑网络延迟或作弊防护。此代码不适用于生产环境。
添加更多敌人
在为我们的游戏添加更多玩家之前,让我们在 MainScene 中添加更多敌人,以使游戏更有趣。我们通过双击 Assets/FPS/Scenes 中的 MainScene 来加载场景,打开 Assets/FPS/Prefabs/Enemies 文件夹,然后将两个敌人中的任何一个拖放到地图上的任意位置。示例项目总共有 12 个敌人。
接下来,我们创建一个标签来分配给所有敌人,以便在脚本代码中轻松检索它们。我们从 Hierarchy 中选择任何一个敌人,单击 Inspector 窗口中的标签,然后选择 Add Tag。然后,我们按加号 (+) 图标,在输入字段中键入“Enemy”,然后单击 Save。最后,我们将新标签分配给场景中的所有敌人,包括游戏中的初始两个敌人。
现在我们可以尝试运行游戏以确保它正常工作。
导入 Unity Transport
我们将为本项目实现一个简单的多人游戏服务器,使用一个基本的网络包 Unity Transport。此包使用 UDP 连接在服务器和客户端之间维护一个快速通信通道。
对于 Unity Mirror 用户,这演示了如何将更自定义的多人游戏服务器项目与 PlayFab 的 API 集成。但 PlayFab 也支持 Unity Mirror,因此如果您已经熟悉该框架,查看此 GitHub 存储库可能会有所帮助。
我们在 Window > Package Manager 中打开 Unity Package Manager,然后单击左上角的加号 (+) 添加新包。然后,我们选择 Add package from git URL,然后键入“com.unity.transport”。
创建服务器脚本
现在是时候为多人游戏服务器逻辑添加一个脚本了。此服务器代码接受来自客户端应用程序的连接,从每个客户端接收游戏状态数据以更新其内部状态,并将其广播给所有连接的玩家。
我们首先在 Assets/Scripts 文件夹中创建一个新的 C# 脚本,我们的新服务器代码将放在这里,并将文件命名为“Server”。然后,从文件菜单中,我们选择 Assets > Open C# Project 以加载整个项目,包括我们在 Visual Studio 中安装的 Unity Transport 包。然后,我们双击新脚本以打开文件。
我们在代码顶部添加以下 using 语句
using System;
using Unity.Collections;
using Unity.Networking.Transport;
接下来,我们将以下成员变量添加到类中,并将 numEnemies
的值调整为我们游戏场景中敌人的总数。此数字必须匹配才能协调所有游戏客户端。此项目目前将在本地运行,但我们添加了一个 RunLocal
变量,可以使用 Unity UI 进行配置,以便在系列的下一部分中轻松切换,届时我们将构建服务器以将其上传到 PlayFab 上的云。
public bool RunLocal;
public NetworkDriver networkDriver;
private NativeList<NetworkConnection> connections;
const int numEnemies = 12; // Total number of enemies
private byte[] enemyStatus;
private int numPlayers = 0;
我们使用以下 StartServer
方法在端口 7777 上创建网络服务器,最多支持 16 个连接,并初始化 enemyStatus
数组,该数组跟踪服务器内的游戏状态。
void StartServer()
{
Debug.Log( "Starting Server" );
// Start transport server
networkDriver = NetworkDriver.Create();
var endpoint = NetworkEndPoint.AnyIpv4;
endpoint.Port = 7777;
if( networkDriver.Bind( endpoint ) != 0 )
{
Debug.Log( "Failed to bind to port " + endpoint.Port );
}
else
{
networkDriver.Listen();
}
connections = new NativeList<NetworkConnection>( 16, Allocator.Persistent );
enemyStatus = new byte[ numEnemies ];
for( int i = 0; i < numEnemies; i++ )
{
enemyStatus[ i ] = 1;
}
}
我们添加以下方法,以便在服务器进程运行时正确清理
void OnDestroy()
{
networkDriver.Dispose();
connections.Dispose();
}
在 Start
方法中,我们调用 StartServer
方法,并为 PlayFab 的启动留下一个占位符。
void Start()
{
if( RunLocal )
{
StartServer(); // Run the server locally
}
else
{
// TODO: Start from PlayFab configuration
}
}
服务器逻辑中最关键的部分是以下 Update
循环。在此方法中,我们更新网络,删除断开连接的客户端,接受新的客户端连接,增加玩家计数,并遍历每个连接,更新服务器的内部游戏状态,并将最新的游戏状态广播给连接的客户端。
void Update()
{
networkDriver.ScheduleUpdate().Complete();
// Clean up connections
for( int i = 0; i < connections.Length; i++ )
{
if( !connections[ i ].IsCreated )
{
connections.RemoveAtSwapBack( i );
--i;
}
}
// Accept new connections
NetworkConnection c;
while( ( c = networkDriver.Accept() ) != default( NetworkConnection ) )
{
connections.Add( c );
Debug.Log( "Accepted a connection" );
numPlayers++;
}
DataStreamReader stream;
for( int i = 0; i < connections.Length; i++ )
{
if( !connections[ i ].IsCreated )
{
continue;
}
NetworkEvent.Type cmd;
while( ( cmd = networkDriver.PopEventForConnection( connections[ i ], out stream ) ) != NetworkEvent.Type.Empty )
{
if( cmd == NetworkEvent.Type.Data )
{
uint number = stream.ReadUInt();
if( number == numEnemies ) // Check that the number of enemies match
{
for( int b = 0; b < numEnemies; b++ )
{
byte isAlive = stream.ReadByte();
if( isAlive == 0 && enemyStatus[ b ] > 0 )
{
Debug.Log( "Enemy " + b + " destroyed by Player " + i );
enemyStatus[ b ] = 0;
}
}
}
}
else if( cmd == NetworkEvent.Type.Disconnect )
{
Debug.Log( "Client disconnected from server" );
connections[ i ] = default( NetworkConnection );
numPlayers--;
}
}
// Broadcast Game State
networkDriver.BeginSend( NetworkPipeline.Null, connections[ i ], out var writer );
writer.WriteUInt( numEnemies );
for( int b = 0; b < numEnemies; b++ )
{
writer.WriteByte( enemyStatus[ b ] );
}
networkDriver.EndSend( writer );
}
}
完整的服务器脚本代码可在此处找到。
将服务器脚本添加到项目中
服务器脚本必须在默认场景上运行。因此,从 Assets/FPS/Scenes,我们双击 IntroMenu 场景来加载它,然后在 Hierarchy 中右键单击以创建一个空的 GameObject。 我们给它一个像“NetworkingServer”这样的名字。
我们单击 Add Component 并选择服务器脚本将其添加到此场景
确保它已启用后,我们选择 RunLocal 标志。此标志仅用于此服务器的本地测试,因此在下一篇文章中将服务器构建以上传到云并在 PlayFab 上部署时,我们需要清除此复选框。
将项目构建为服务器可执行文件
通过打开 File > Build Settings,选择 Server Build 复选框,然后单击 Build,我们将项目构建为“无头”网络服务器。这会创建一个非图形化的、仅服务器的项目版本,我们可以在没有图形的情况下运行它,稍后,将其上传到 PlayFab 的虚拟机上运行。
我们的多人游戏服务器代码设置就完成了!
现在,我们单击 Build 并将其保存到单独的文件夹中。运行服务器应用程序时,我们会看到一个类似这样的控制台窗口。
创建客户端
现在我们有了服务器程序,我们可以设置我们游戏的客户端来连接和玩。
我们清除 Project Settings 中的 Server Build 设置,因为我们不再构建“无头”应用程序。
另外,从 IntroMenu 中清除 Server script,因为客户端不需要运行它。
我们在构建游戏服务器和构建游戏客户端之间切换时,需要随时切换这些设置。
客户端代码位于 MainScene 中,因为这是游戏发生的地方。因此,让我们双击并从 Assets/FPS/Scenes 中打开 MainScene,并在 Assets/Scripts 文件夹中添加一个名为 Client.cs 的新脚本,就像我们创建服务器脚本一样。
我们在 MainScene 中添加一个名为“NetworkClient”的空 GameObject,并将 Client.cs 脚本添加为组件。
我们已准备好添加客户端代码,因此我们双击脚本文件在 Visual Studio 中打开它。
我们将以下 using 语句添加到代码中
using UnityEngine;
using System;
using Unity.Networking.Transport;
using Unity.FPS.AI;
这些是类中需要的成员变量。这些变量包括一个与服务器脚本中的标志类似的客户端的 RunLocal
标志,用于管理连接的网络相关变量,以及用于使用 GameObject
类和 EnemyManager
类来跟踪敌人的变量。
public bool RunLocal;
private NetworkDriver networkDriver;
private NetworkConnection networkConnection;
private bool isDone;
private EnemyManager enemyManager;
private GameObject[] enemies;
private byte[] enemyStatus;
private bool startedConnectionRequest = false;
private bool isConnected = false;
我们需要访问 EnemyManager
来调用方法以正确销毁机器人并触发爆炸动画。为此,我们必须对样本中的 EnemyController
类进行一些小的修改。我们在 EnemyManager
类中右键单击 Definition,选择 Go To,然后右键单击并选择 EnemyController
类。
然后,在 EnemyController
类中,我们在 m_Health member
变量前面添加 public
关键字,以便从我们的客户端脚本访问此对象。
我们返回 Client.cs 脚本,并添加以下方法,该方法用于连接到服务器
private void connectToServer( string address, ushort port )
{
Debug.Log( "Connecting to " + address + ":" + port );
networkDriver = NetworkDriver.Create();
networkConnection = default( NetworkConnection );
var endpoint = NetworkEndPoint.Parse( address, port );
networkConnection = networkDriver.Connect( endpoint );
startedConnectionRequest = true;
enemyManager = FindObjectOfType<EnemyManager>();
enemies = GameObject.FindGameObjectsWithTag( "Enemy" );
Debug.Log( "Detected " + enemies.Length + " enemies" );
// Sort the array by name to keep it consistent across clients
Array.Sort( enemies, ( e1, e2 ) => e1.name.CompareTo( e2.name ) );
int length = enemies.Length;
enemyStatus = new byte[ length ];
for( var i = 0; i < length; i++ )
{
if( enemies[ i ] != null )
{
enemyStatus[ i ] = (byte)( enemies[ i ].activeSelf ? 1 : 0 );
}
}
}
此方法启动与服务器的连接并检索场景中的所有敌人。然后,由于 GameObject.FindGameObjectsWithTag
的对象顺序不一致,因此它按名称对它们进行排序,以使游戏状态在所有客户端中保持一致。然后,它更新游戏状态以匹配启用的敌人对象和禁用的对象。
此外,我们添加了一个 OnDestroy
处理程序,以在程序完成后清理网络。
public void OnDestroy()
{
networkDriver.Dispose();
}
接下来,我们在 Start
方法中调用服务器连接方法以连接到本地计算机,并牢记 RunLocal
标志。
void Start()
{
Debug.Log( "Starting Client" );
if( RunLocal )
{
connectToServer( "127.0.0.1", 7777 );
}
else
{
// TODO: Start from PlayFab configuration
}
}
最后,我们添加 Update
循环,该循环处理网络连接状态,获取来自服务器的广播游戏状态,并使用 EnemyController Health
对象来触发爆炸并销毁任何报告已从服务器消失的机器人。它还更新敌人状态的当前游戏状态,以检查由本客户端玩家杀死的敌人,然后将最新的游戏状态发送到服务器进行同步。
void Update()
{
if( !startedConnectionRequest )
{
return;
}
networkDriver.ScheduleUpdate().Complete();
if( !networkConnection.IsCreated )
{
if( !isDone )
{
Debug.Log( "Something went wrong during connect" );
}
return;
}
DataStreamReader stream;
NetworkEvent.Type cmd;
if( !isConnected )
{
Debug.Log( "Connecting..." );
}
while( ( cmd = networkConnection.PopEvent( networkDriver, out stream ) ) != NetworkEvent.Type.Empty )
{
if( cmd == NetworkEvent.Type.Connect )
{
Debug.Log( "We are now connected to the server" );
isConnected = true;
}
else if( cmd == NetworkEvent.Type.Data )
{
uint value = stream.ReadUInt();
if( value == enemyStatus.Length ) // Make sure the enemy length is consistent
{
for( int b = 0; b < enemyStatus.Length; b++ )
{
byte isAlive = stream.ReadByte();
if( enemyStatus[ b ] > 0 && isAlive == 0 ) // Enemy is alive locally but dead on the server
{
Debug.Log( "enemy " + b + " dead" );
// Find the right enemy and "kill" it for the animation
foreach( var en in enemyManager.Enemies )
{
if( en.name == enemies[ b ].name )
{
en.m_Health.Kill();
break;
}
}
enemyStatus[ b ] = 0;
}
}
}
}
else if( cmd == NetworkEvent.Type.Disconnect )
{
Debug.Log( "Client got disconnected from server" );
networkConnection = default( NetworkConnection );
isConnected = false;
}
}
// Update the status with local game state
for( var i = 0; i < enemies.Length; i++ )
{
if( enemyStatus[ i ] > 0 &&
( enemies[ i ] == null || !enemies[ i ].activeSelf ) )
{
enemyStatus[ i ] = 0;
}
}
// Send latest status to the server
if( isConnected )
{
networkDriver.BeginSend( networkConnection, out var writer );
writer.WriteUInt( (uint)enemyStatus.Length );
for( int b = 0; b < enemyStatus.Length; b++ )
{
writer.WriteByte( enemyStatus[ b ] );
}
networkDriver.EndSend( writer );
}
}
我们终于准备好运行我们的多人游戏了!
供参考,完整的客户端代码应如下所示:如此。
与服务器一起运行
完成所有这些后,我们必须确保在 NetworkClient 空 GameObject 上启用 RunLocal。我们可以将游戏客户端构建并保存到单独的文件夹中,就像我们对服务器所做的那样。这使我们能够运行多个游戏实例并测试多人通信。
我们运行先前保存的服务器程序,然后打开两个或多个游戏实例。
现在,当我们射击并摧毁一个机器人时,我们应该会在其他游戏实例中也看到该机器人爆炸。
后续步骤
在本篇文章中,我们学习了如何将 Unity 项目转换为服务器构建和游戏客户端构建,并为多人游戏代码奠定基础。然后,我们学习了如何将项目构建为仅服务器的“无头”可执行文件,同时游戏正常构建。
请访问下一篇文章,我们将集成 PlayFab GSDK 以准备云托管,然后通过仪表板将其上传并部署到 PlayFab。
要了解有关 Azure PlayFab 分析的更多信息,并获取功能概述、快速入门指南和教程,请查看Azure PlayFab 分析文档。