多人联机一直是一项细节性强且复杂的工作,包含很多细粒度的问题,比如如何让位于世界各地的各类设备实现数据同步以及交流。通过Unity的内嵌的多人联机系统以及HLAPI(High Level API),我们希望能够为这个问题提供一个简单的解决方案。
通过这个简单的联机示例,我们会展示如何从零开始,使用最简单的脚本和资源搭建一个多人联机项目。我们希望你能够在读完这篇文档之后,能够迅速掌握我们的多人联机系统与HLAPI的使用方法。
这篇文档会手把手地展示如何使用Unity内嵌的多人联机系统和HLAPI搭建一个多人联机项目。我们设计的每一步不仅泛用性强,而且包含许多关于多人联机的重要概念。开发者能够根据自己的需要扩展这些步骤以适应不同类型的游戏。当项目开发完毕后,它将能够支持两名玩家在两个不同的项目实例上独立地控制自己的角色,服务器会负责角色行为的控制和同步。玩家之间能够互相射击,也可以射击其他敌人。当玩家被击败时,他控制的游戏角色会复位。
这篇文档适合中级开发者阅读。我们希望开发者能够能够先阅读一下我们的多人联机开发手册,特别是Networking Overview部分以及The High Level API以及它们的子页,包括Network System Concepts。
开始之前,请先:
- 创建一个空的Unity3D项目。
- 将默认的scene保存为”Main”。
使用联网特性的开发者大致可以分为两类:
- 使用Unity开发联机游戏的开发者。这类开发者应当首先阅读NetworkManager部分或是High Level API部分。
- 搭建网络基础部分以及开发高级联机游戏的开发者。这类开发者应当首先阅读NetworkTransport API部分。
Unity的联网系统有一组高级脚本API(HLAPI)。这些方法基本上能覆盖绝大部分的多人游戏的共同需求。使用这些API,你可以忽略底层细节,专注于功能的开发。简单来讲,这些API能够:
- 使用NetworkManager控制游戏的连接状态。
- 管理”客户端主机”游戏,这类游戏中的主机由一个玩家客户端扮演。
- 使用一个多功能的serializer序列化数据。
- 发送以及接收网络消息。
- 从客户端向服务器端发送指令。
- 实现客户端到服务器端的远程过程调用(RPC)。
- 从客户端向服务器端发送网络事件。
Unity的网络系统嵌入到了它的编辑器和引擎中,这让网络游戏的开发变得可视化。它提供了:
- NetworkIdentity,用于需要联网的组件。
- NetworkBehaviour,用于联机脚本。
- 可配置的对象变化自动同步。
- 脚本变量自动同步。
- 在Unity Scene中放置网络构件。
- Network components
Unity提供了网络服务来为你的游戏开发提供便利,包括以下功能:
- 比赛匹配。
- 创建比赛以及通告比赛。
- 显示可用的比赛以及加入。
- 中继服务器(Relay Server)。
- 无服务器的联网对局。
- 向比赛参与者发送消息。
Unity提供了实时传输层(real-time transport layer),提供了:
- 最优化的基于UDP的传输协议。
- 多通道设计,用于避免队头消息阻塞。
- 支持设置每个通道的服务质量(QoS)。
- 灵活的网络拓扑结构,支持端到端以及客户端-服务器结构。
这里可以找到示例项目:https://forum.unity.com/threads/unet-sample-projects.331978/?_ga=2.37697120.1374918886.1508243698-263594280.1507883404
包括:
- 多人2D坦克对战游戏。
- 多人战争游戏,支持比赛匹配。
- 多人太空射击游戏,支持比赛匹配。
- 最简单的多人联机项目。
这节课中,我们将创建一个新的Network Manager对象。这个Network Manager对象会控制这个多人联机项目的状态信息,包括游戏状态管理,场景管理,比赛创建,并且支持访问Debug信息。高级开发者还能够扩展NetworkManager类来自定义组件的行为,不过这部分内容不会包含在这节课中。
想要创建一个新的Network Manager对象,我们需要创建一个新的GameObject,并为其加上NetworkManager与NetworkManagerHUD组件(Component):
- 创建一个空的Object;
- 将其重命名为”Network Manager”;
- 选中这个Object;
- 添加组件(Add Component):Network > NetworkManager;
- 添加组件(Add Component):Network > NetworkManagerHUD;
NetworkManager组件会管理游戏的联网状态。
NetworkManagerHUD组件和NetworkManager协同工作,并提供了一个简单的用户接口来控制游戏在运行时的联网状态。
在运行时,NetworkManagerHUD看起来会像是这样:
这里可以找到更多细节:https://docs.unity3d.com/Manual/UNetManager.html?_ga=2.40090082.1374918886.1508243698-263594280.1507883404
在这个项目中,player预制件用于代表玩家们。
默认情况下,NetworkManager会通过克隆player预制件并生成到游戏中来为每个连接进游戏的玩家实例化一个游戏对象。
网络生成(Network Spawning)以及在客户端和服务器上同步游戏对象的细节会在后面的课程中介绍。
这里,玩家的GameObject会是一个简单的胶囊体,上面附着一个”脸”,用来告诉我们这个胶囊体的朝向。
完成后的GameObject会是这样:
要创建这个GameObject,你需要:
- 创建一个Capsule。
- 将其重命名为”Player”。
为了指示出这个对象的“前方”,为它添加一个子立方体,并将颜色设置为黑色:
- 选中Player。
- 创建一个立方体,并将其设置为Player的子物体。
- 将其重命名为”Visor”。
- 设置它的Scale为(0.95, 0.25, 0.5)。
- 设置它的Position为 (0.0, 0.5, 0.24)。
- 创建一个新的Material。
- 将其重命名为”Black”。
- 选中Black。
- 将它的Albedo color改为黑色。
- 将Visor的Material设置为Black。简单的方法是直接把Material拖到Scene视图的Visor上。
为了将Player标识为一个特殊的联网的游戏对象,为Player添加一个NetworkIdentity组件:
- 选中Player。
- 添加组件(Add Component):Network > NetworkIdentity。
NetworkIdentity组件用来在网络上识别这个物体,并让网络系统意识到它。
- 将 Local Player Authority 设置为true。
将Player的NetworkIdentity设置为Local Player Authority会允许客户端控制Player的移动。
接下来由Player创建一个预制件:
- 把Player从Hierarchy视图拖到Project视图来创建一个新的prefab资源。
- 从场景中删除Player。
- 保存场景。
当Player预制件创建完毕后,我们需要对其进行注册。Network Manager会用这个预制件】来生成新的玩家控制的对象,并置入场景中。
- 在Hierarchy视图中选中之前创建的Network Manager。
- 在Inspector视图中打开Spawn Info标签。
- 把Player预制件拖进Player Prefab域中。
NetworkManager组件被用来控制联机对象的生成,包括Player。在许多游戏中,玩家都会有一个归自己控制的标志性的对象。NetworkManager有一个专门的域,用来存放用于代表玩家的Player预制件。每个进入游戏的玩家客户端都会得到一个新创建的游戏对象
接下来我们会制作游戏的第一个功能特性:在场景中移动Player。为此,我们会编写一个新的脚本,叫做“PlayerController”。
首先编写最简单的代码部分,这部分不会涉及到联网功能,仅仅在单一玩家环境下工作。
- 为Player预制件(prefab)创建一个新的脚本,命名为”PlayerController”。
- 打开脚本编辑器。
- 写入如下代码:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
void Update()
{
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
}
}
这个简单的脚本能实现移动人物与转向的功能。默认情况下Input.GetAxis("Horizontal")
与Input.GetAxis("Vertical")
允许玩家通过WASD与方向键乃至触摸面板来控制玩家。如果想要改变键位,请查询Input Manager相关内容(Edit-Project Settings-Input)。
接下来:
- 保存脚本。
- 回到Unity。
- 保存场景。
现在,Player还只能够在客户端上移动,没有联网功能。
想要进行联网测试:
- 进入Play模式。
在Play模式中,NetworkManagerHUD默认显示如下:
- 单击LAN Host按钮,这能够让你以主机的身份启动游戏。
NetworkManager会用Player预制件创建一个新的Player,NetworkManagerHUD会改变显示来表明服务器目前处于活动状态。
这种情况下,游戏以”Host”模式运行。服务器和客户端处于同一个进程中。
接下来:
- 用WASD来控制Player。
- 单击Stop按钮来断开连接。
- 退出Player模式。
想要以客户端的身份进行测试,我们需要两个同时运行的游戏实例,其中一个扮演Host。一个可以从编辑器中打开,而另一个就必须首先Build项目之后才能打开。因此,如果我们想在客户端上测试游戏,就必须Build这个项目。
- File-Build Settings。
- 加入场景Main。
- 点击Build and run。
- 启动时选择一个较小的分辨率,保证能够同时看到编辑器。
游戏启动(后面称这个实例为Instance)后,你应该能看到NetworkManagerHUD面板。
- 点击Host按钮,这样Instance会扮演Host。
这时你应该能看到一个Player。试着用WASD控制它。之后:
- 返回Unity。
- 进入Play模式。
- 点击LAN Client按钮来扮演客户端,并和Host建立连接。
- 试着用WASD控制它。
你会发现两个Player都在移动。这时:
- 回到Instance。
你应该还会发现,在Editor中两个Player的位置和Instance中不同。这是因为PlayerController脚本现在还没有联网功能。当前,两个Player上都附着同样的脚本。在两个不同的实例中,这两个脚本都在处理同样的输入信息。Host和Client彼此都能意识到对方的存在,NetworkManager也为它们分别创建了两个不同的Player,但是Player对象没有和Host进行交流,因此NetworkManager无法追踪它的位置,简单来说就是没有同步。
接下来:
- 关掉Instance。
- 回到Unity。
- 退出Play模式。
为了给Player的移动赋予在线特性,并保证每个玩家只能控制它们自己的Player,我们需要更新PlayerController脚本。我们需要给脚本做两个大的改动:使用UnityEngine.Networking命名空间,以及让PlayerController继承自NetworkBehaviour,而不是MonoBehaviour。
- 打开PlayerController脚本。
- 添加UnityEngine.Networking命名空间。using UnityEngine.Networking;
- 将MonoBehaviour改成NetworkBehaviour。public class PlayerController : NetworkBehaviour
接下来加入一段代码,用于检查是不是本地对象,这样就能保证只有玩家只能控制对应的Player。
if (!isLocalPlayer)
{
return;
}
下面是完整的脚本:
using UnityEngine;
using UnityEngine.Networking;
public class PlayerController : NetworkBehaviour
{
void Update()
{
if (!isLocalPlayer)
{
return;
}
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
}
}
UnityEngine.Networking命名空间包含了编写具有联网功能的脚本所需的内容。
NetworkBehaviour是一个基于Monobehaviour的特别的类。所有自定义的,使用联网特性的脚本都继承自它。
注意到isLocalPlayer。LocalPlayer是NetworkBehaviour的一部分,所有继承自NetworkBehaviour的脚本都能够理解它的含义。想要理解LocalPlayer是什么,以及它是如何工作的的话,需要查阅关于HLAPI的文档。
在一个联机项目中,无论是服务器还是客户端,执行的代码都来自于同一个脚本,在上文中就是PlayerController脚本。假设有一个服务器端和两个客户端,那么就会有6个需要处理的游戏对象。这是因为玩家有两名,服务器端与两个客户端都会存在这两个玩家控制的游戏对象,2×3=6。
每个游戏对象都是从同一个预制件克隆得到的,因此它们拥有同样的脚本文件。如果脚本是继承自NetworkBehaviour的,那么它就能够了解到哪个对象属于哪个玩家。LocalPlayer就是对应的客户端拥有的游戏对象。这个归属关系是由NetworkManager,在玩家连接进游戏时建立的。当客户端连接上服务器后,客户端上创建的游戏对象就被标识为LocalPlayer。其他的游戏对象,无论在客户端上还是服务器上,都不会被LocalPlayer。
通过检查isLocalPlayer,我们就可以判断脚本是否要继续执行。
if (!isLocalPlayer)
{
return;
}
这个判断保证了只有LocalPlayer能够执行移动对象的代码。
这里还有一个问题,如果我们现在进行测试,Player对象依然没有实现同步。每个Player只会在本地进行移动,而不会实时更新到网络上去。为了保证同步,我们需要为Player添加一个NetworkTransform组件。
- 保存脚本。
- 回到Unity。
- 在Project视图中选中Player预制件。
- Add Component-Network > NetworkTransform。
- 保存。
NetworkTransform会同步GameObject的移动与变化。
总结一下本节的内容:
- isLocalPlayer检查保证了玩家只能控制自己的Player。
- NetworkTransform实现了Player之间的同步。
测试之前,首先要把之前Build得到的旧版游戏删除,并重新Build一个新版本。之后的步骤和(6)中相同。如果你前面的步骤没有出错的话,此时两个Player应当能够独立地移动,并且实现了同步。你可能会感觉到远端的Player的移动不是很平滑,有一点卡顿的感觉。你需要记住一点:所有需要联网的应用都回或多或少地收到网络条件的限制,也就是客户端和服务器间数据传输的速度。
有一些方法可以优化网络状况与数据传输。比如,NetworkTransform有一个Network Send Rate设置,能够确定NetworkTransform发送同步数据的频率。这对玩家的游戏体验会有非常大的影响。
更重要的是,一个需要联网的应用有许多方法可以解决不同步的问题,比如插值、外推法以及其他不同形式的平滑与预测技术。不过我们的课程中不会涉及到这些。
最好能够记住一些关键的概念。我们应当在同步数据的频率和游戏表现(Performance)上取得一个平衡。同步数据的频率过高或是过低都不合适。如果想要让用户有较好的游戏体验,最好能够为那些需要同步的游戏对象进行一些预测,让它们看起来似乎在平滑移动。任何联机游戏都不可能做到完美的同步,因为玩家所处的网络环境有好有坏。但是游戏开发者应当付出一些努力,即使在比较差的网络环境下,至少要让玩家感觉游戏的同步状态还不错。接下来:
- 关掉Instance。
- 回到Unity。
- 退出Play模式。
现在,每个玩家控制的Player都是一模一样的,这让玩家无法判断哪个Player属于它。为了标识不同的玩家,我们需要给Player上色。
- 打开PlayerController。
- 覆盖OnStartLocalPlayer方法来为Player上色。
public override void OnStartLocalPlayer()
{
GetComponent().material.color = Color.blue;
}
此时,完整的代码如下:
using UnityEngine;
using UnityEngine.Networking;
public class PlayerController : NetworkBehaviour
{
void Update()
{
if (!isLocalPlayer)
{
return;
}
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
}
public override void OnStartLocalPlayer()
{
GetComponent().material.color = Color.blue;
}
}
这个方法只会在LocalPlayer上调用,因此每个玩家看他们自己的Player时会发现是蓝色的。OnStartLocalPlayer方法用来放置一些只会作用与LocalPlayer的代码,比如配置摄像机与输入。
NetworkBehaviour中还有许多有用的方法,最好去查查它的文档。
接下来:
- 保存脚本。
- 回到Unity。
- 进行联网测试(同8)。
你会发现自己控制的角色是蓝色的。
射击是联机游戏的一个经典游戏内容,玩家们能够发射子弹,这些子弹在每个客户端都能看到。本节会先介绍怎么在单机环境下发射子弹,联机部分在下一节。
- 创建一个新的Sphere Object。
- 重命名为”Bullet”。
- 选中Bullet。
- 将其Scale改为(0.2, 0.2, 0.2)。
- Add Component-Physics > Rigidbody。
- 设置Rigidbody的Use Gravity为false。
- 把它拖到Project视图中,创建一个Bullet预制件。
- 从场景中删除Bullet。
- 保存场景。
现在需要更新PlayerController脚本,让它具有发射子弹的功能。为此,脚本需要持有Bullet的一个引用。
- 打开PlayerController脚本。
- 为Bullet添加一个public的域。
public GameObject bulletPrefab;
public Transform bulletSpawn;
if (Input.GetKeyDown(KeyCode.Space))
{
Fire();
}
void Fire()
{
// Create the Bullet from the Bullet Prefab
var bullet = (GameObject)Instantiate (
bulletPrefab,
bulletSpawn.position,
bulletSpawn.rotation);
// Add velocity to the bullet
bullet.GetComponent().velocity = bullet.transform.forward * 6;
// Destroy the bullet after 2 seconds
Destroy(bullet, 2.0f);
}
最终的脚本如下:
using UnityEngine;
using UnityEngine.Networking;
public class PlayerController : NetworkBehaviour
{
public GameObject bulletPrefab;
public Transform bulletSpawn;
void Update()
{
if (!isLocalPlayer)
{
return;
}
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
if (Input.GetKeyDown(KeyCode.Space))
{
Fire();
}
}
void Fire()
{
// Create the Bullet from the Bullet Prefab
var bullet = (GameObject)Instantiate(
bulletPrefab,
bulletSpawn.position,
bulletSpawn.rotation);
// Add velocity to the bullet
bullet.GetComponent().velocity = bullet.transform.forward * 6;
// Destroy the bullet after 2 seconds
Destroy(bullet, 2.0f);
}
public override void OnStartLocalPlayer ()
{
GetComponent().material.color = Color.blue;
}
}
接下来创建一把枪:
- 把Player预制件拖进Scene中。
- 选中Player。
- 创建一个Cylinder作为它的子物体。
- 将其重命名为”Gun”。
- 选中Gun。
- 移除它的Capsule Collider组件。
- 设置Position为:(0.5, 0.0, 0.5)。
- 设置Rotation为:(90.0, 0.0, 0.0)。
- 设置Scale为:(0.25, 0.5, 0.25)。
- 设置Material为Black。
完成图:
接下来创建子弹发射器:
- 选中Player。
- 创建一个空GameObject作为其子物体。
- 将其重命名为Bullet Spawn。
- 设置其Position为(0.5, 0.0, 1.0)。
创建完毕后,子弹发射器应当在枪口位置:
- 选中Player。
- 将改动保存到Player预制件中。(直接拖到Project视图的Player上)。
- 从场景中删除Player。
- 保存。
接下来要为PlayerController脚本设置Bullet与Bullet Spawn的引用。
- 选中Player。
- 在Inspector视图中打开Player Controller标签。
- 设置Bullet Prefab。
- 设置Bullet Spawn。
- 保存。
接下来你可以进行单机测试和联机测试。你会发现玩家只能看到自己发射的子弹。
这部分会为Bullet添加联机特性。我们需要更新Bullet预制件和射击代码。
前面的课程已经告诉我们,想要让Bullet具有联机特性,需要为其添加NetworkIdentity来标识它在网络上的独特性,添加NetworkTransform来同步它的位置和旋转。除此之外,还要向Network Manager将其注册为一个Spawnable Prefab。
- 选中Bullet预制件。
- add component: Network > NetworkIdentity。
- add component: Network > NetworkTransform。
- 在NetworkTransform中,将Network Send Rate设置为0。
Bullet在射出之后,方向、速度和旋转角都不会发生变化,因此不需要发送更新信息。每个客户端都能够自己计算出子弹在某个时刻所处的位置。通过将Network Send Rate设置为0,子弹的位置信息将不会通过网络同步,因此可以降低网络负载。
接下来:
- 在Hierarchy视图中选中NetworkManager。
- 打开Spawn Info标签。
- 在Registered Spawnable Prefabs列表中,通过+按钮添加一行。
- 选中NetworkManager。
- 把Bullet加入到Registered Spawnable Prefabs列表中。
现在,我们需要更新PlayerController脚本。从脚本编辑器中打开它。
之前,当我们讨论如何让Player的移动具有联机特性时,我们提到过HLAPI的结构。一个基础概念是服务器和所有客户端执行的都是同样的脚本。想要区分开服务器和不同客户端的行为,需要用isLocalPlayer来进行判定。
另外一种控制方法是使用[Command]特性(Attribute)。[Command]用来指明某个方法是由客户端调用,但是是在服务器上运行的。方法所需的参数都会和命令一起被传递到服务器端。命令只能从本地项目实例中发出。当创建一个Command时,Command对应的方法必须以Cmd开头。
- 为Fire方法添加[Command]特性,使其成为一个Command。
- 将其名称改为CmdFire。
[Command]
void CmdFire()
CmdFire();
下一个需要知道的概念是Network Spawning(网络生成?)。在HLAPI中,”Spawn”不仅仅包含”Instantiate”,它意味着在服务器和所有与其连接的客户端上创建一个对象。这个对象会由spawning system(生成系统?)管理,当其在服务器上发生改变时,状态变更信息会被发送到客户端。当服务器上的该对象被摧毁时,客户端上的该对象也会被摧毁。除此之外,网络系统还会持有所有spawned GameObject(生成的对象?)的引用,如果一个新玩家加入,这些对象也会在新玩家的客户端上生成。
你需要在CmdFire方法中添加这么一行代码:
NetworkServer.Spawn(bullet);
这是最终的脚本:
using UnityEngine;
using UnityEngine.Networking;
public class PlayerController : NetworkBehaviour
{
public GameObject bulletPrefab;
public Transform bulletSpawn;
void Update()
{
if (!isLocalPlayer)
{
return;
}
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
if (Input.GetKeyDown(KeyCode.Space))
{
CmdFire();
}
}
// This [Command] code is called on the Client …
// … but it is run on the Server!
[Command]
void CmdFire()
{
// Create the Bullet from the Bullet Prefab
var bullet = (GameObject)Instantiate(
bulletPrefab,
bulletSpawn.position,
bulletSpawn.rotation);
// Add velocity to the bullet
bullet.GetComponent().velocity = bullet.transform.forward * 6;
// Spawn the bullet on the Clients
NetworkServer.Spawn(bullet);
// Destroy the bullet after 2 seconds
Destroy(bullet, 2.0f);
}
public override void OnStartLocalPlayer ()
{
GetComponent().material.color = Color.blue;
}
}
保存并回到Unity。接下来你可以进行测试了。不出意外的话,子弹已经能够正确显示在各个窗口中了。不过,现在子弹只会在其他玩家身上弹开,不会产生任何影响。
在网络游戏中,同步玩家状态信息是很重要的一个概念。下面我们会为Bullet添加伤害效果,被Bullet击中会削减玩家的HP值。玩家的HP值就是一个需要在网络上进行同步的数据。
首先为Bullet创建碰撞处理逻辑。这里,我们仅仅让子弹在撞到其他物体时摧毁自己。
- 为Bullet预制件添加一个脚本,并改名为”Bullet”。
- 打开脚本编辑器。
- 补充逻辑(新版中方法签名不太一样):
using UnityEngine;
using System.Collections;
public class Bullet : MonoBehaviour {
void OnCollisionEnter()
{
Destroy(gameObject);
}
}
现在你可以进行一下测试。当子弹碰到其他玩家时,所有窗口中该子弹都会消失。
为了创建玩家的HP,我们需要一个新的脚本来追踪我们的Player的当前HP。
- 为Player预制件创建一个新的脚本,并取名为”Health”。
- 打开脚本编辑器。
- 创建一个常量来确定HP最大值。
public const int maxHealth = 100;
public int currentHealth = maxHealth;
添加一个方法来削减HP:
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Dead!");
}
}
接下来,我们需要修改Bullet脚本的OnCollisionEnter方法,添加以下代码:
var hit = collision.gameObject;
var health = hit.GetComponent();
if (health != null)
{
health.TakeDamage(10);
}
为了创建血槽,我们需要创建一些简单的UI组件。下面的方法并不是最佳的选择,我们仅仅是希望能用最简单的方式来解决这个问题。
- 创建一个UI Image。
需要注意的是,这也会同时创建一个Canvas父对象和一个EventSystem对象。
- 将Canvas改名为”Healthbar Canvas”。
- 将Image改名为”Background”。
- 选中Background。
- 将Width设置为100。
- 将Height设置为10。
- 将它的Source Image设置为内置的InputFieldBackground。
- 设置其颜色为Red。
- 不要改动它的Anchor与Pivot。
- 拷贝BackGround对象。
- 将新的改名为”Foreground”,并将其设置为Background的子对象。
- 选中Foreground。
- 将其设置为绿色。
- 打开Anchor Presets,并将其Pivot与Position设置为Middle Left。
这个HealthBar需要被加入到Player预制件中,并且和生命值与伤害系统绑定起来。
首先,需要将Canvas从默认的Overlay Canvas改成一个World Space Canvas,然后再将其加入到Player预制件中。
- 将Player预制件拖进Scene中。
- 选中HealthBar。
- 将Canvas的Render Mode改为World Space。
- 让HealthBar成为Player的子对象。
此时的结构大致是这样:
- 选中HealthBar。
- 对RectTransform执行reset(右上角的小齿轮)。
- 将RectTransform的Scale设置为(0.01, 0.01, 0.01)。
- 将RectTransform的Position设置为(0.0, 1.5, 0.0)。
- 选中Player。
- 将改动保存进Player预制件中。
- 保存。
为了将血槽绑定到生命值与伤害系统中,我们需要让Health脚本获取它的引用,并根据当前HP设置Foreground的宽度。
- 打开Health脚本。
- 添加UnityEngine.UI命名空间。
using UnityEngine.UI;
public RectTransform healthBar;
这里我们需要引用的是HealthBar的Foreground的RectTransform的引用。有了这个引用,我们只需要根据当前血量设置它的Width属性就可以了。
healthBar.sizeDelta = new Vector2(
currentHealth,
healthBar.sizeDelta.y);
这里我们使用了Vector2来设置其width与height。
完整的Health脚本如下:
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class Health : MonoBehaviour {
public const int maxHealth = 100;
public int currentHealth = maxHealth;
public RectTransform healthBar;
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Dead!");
}
healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
}
}
最后,我们需要让HealthBar始终面朝主摄像机。
- 选中HealthBar。
- 为其添加一个新的脚本,起名为Billboard。
- 打开脚本编辑器。
- 在Update方法中添加逻辑,使得HealthBar始终面朝主摄像机。
transform.LookAt(Camera.main.transform);
完整的Billboard脚本如下:
using UnityEngine;
using System.Collections;
public class Billboard : MonoBehaviour {
void Update () {
transform.LookAt(Camera.main.transform);
}
}
现在你可以进行测试了。没有问题的话,你应该能够通过射击削减目标的HP了。
现在,玩家HP的变化是在服务器和客户端上独立进行的。当一个玩家射击另一个玩家,客户端和服务器都在运行Bullet与Player的脚本。这里没有进行任何同步。然而,子弹是由NetworkManager控制生成的。当检测到碰撞时,所有客户端上的子弹都会被摧毁。由于子弹在每个客户端上都存在,因此子弹和玩家之间会有碰撞,玩家能够受到来自子弹的伤害。但是,由于网络状态的不稳定性,可能在某个客户端上子弹已经发生了碰撞,而在另一个客户端上子弹还没生成。由于子弹是同步的,而HP不是同步的,玩家的HP在不同的客户端上可能会产生差异。
想要解决上一节留下来的问题,一个方式是让HP的变化仅仅发生在服务器上,之后再让服务器对所有客户端上的玩家血量进行同步。这个概念被称为服务器权限(Server Authority)。
为了让我们的生命值和伤害系统在服务器权限下工作,我们需要使用状态同步(State Synchronization)和一个特殊的变量:SyncVars。需要网络同步的变量,或者说SyncVars,需要加上[SyncVar]特性。
- 打开Health脚本。
- 添加UnityEngine.Networking命名空间。
- 让脚本继承自NetworkBehaviour。
- 为currentHealth加上[SyncVar]特性。
[SyncVar]
public int currentHealth = maxHealth;
if (!isServer)
{
return;
}
最终的Health脚本如下:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;
public class Health : NetworkBehaviour {
public const int maxHealth = 100;
[SyncVar]
public int currentHealth = maxHealth;
public RectTransform healthBar;
public void TakeDamage(int amount)
{
if (!isServer)
{
return;
}
currentHealth -= amount;
if (currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Dead!");
}
healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
}
}
现在你可以进行测试了。这里建议你让Instance作为Host,编辑器作为客户端,并控制Instance的Player射击编辑器的Player。你会发现,Instance上的显示是正确的,而Client上血条没有变化。但是,如果你在Inspector视图中查看Player的当前血量属性,你会发现它的确发生了变化:
这是因为我们仅仅同步了HP值,而没有同步Foreground的宽度。改变Foreground宽度的代码在TakeDamage中,而这个方法由于isServer判定而只能在服务器上运行,因此才会出现服务器上能够正常显示,而客户端上无法正确显示的问题。
现在我们需要对Foreground的宽度进行同步。这里我们需要使用另外一个状态同步工具:SyncVar hook。SyncVar hook能够将SyncVar连接到一个方法,当SyncVar发生变化,服务器和所有客户端上的这个方法都会被调用。需要注意的是,这个方法必须有一个参数,类型和SyncVar相同。当方法被调用时,SyncVar的当前值会被传到方法的参数中。
下面演示它的使用方法:
- 打开Health脚本。
- 把改变Foreground宽度的代码移到一个单独的方法中。
void OnChangeHealth (int currentHealth)
{
healthBar.sizeDelta = new Vector2(health, currentHealth.sizeDelta.y);
}
[SyncVar(hook = "OnChangeHealth")]
最终的Health脚本如下:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;
public class Health : NetworkBehaviour {
public const int maxHealth = 100;
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public RectTransform healthBar;
public void TakeDamage(int amount)
{
if (!isServer)
return;
currentHealth -= amount;
if (currentHealth <= 0)
{
currentHealth = 0;
Debug.Log("Dead!");
}
}
void OnChangeHealth (int health)
{
healthBar.sizeDelta = new Vector2(health, healthBar.sizeDelta.y);
}
}
现在你可以进行测试了。此时客户端和服务器上的血条应当都能正确变化了。
现在,即便玩家的HP归零也不会发生任何事情。为了让这个示例更加像一个游戏,我们让玩家的Player在HP归零时自动在出生点满HP复活。这里会用到状态同步的另一个工具——[ClientRpc]特性。
ClientRpc指令可以由任何一个拥有NetworkIdentity的生成对象发出。这个方法由服务器调用,但在客户端上执行。ClientRpc恰好是Command的反义词。Command是由客户端调用,但由服务器执行。
为了让一个方法称为Rpc方法,我们需要使用[ClientRpc]特性,并在方法的名字的前面加上Rpc。现在,这个方法会在客户端上运行。尽管它是在服务器上调用的。方法所需的参数会自动被发送到客户端。
为了添加一个复位功能,我们需要在Health脚本中创建一个新的Respawn方法,并在TakeDamage中进行判定,如果HP归零则调用这个方法。
- 打开Health脚本。
- 创建一个新的方法,命名为RpcRespawn,并加上[ClientRpc]特性。
[ClientRpc]
void RpcRespawn()
{
if (isLocalPlayer)
{
// move back to zero location
transform.position = Vector3.zero;
}
}
最终的Health脚本如下:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;
public class Health : NetworkBehaviour {
public const int maxHealth = 100;
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public RectTransform healthBar;
public void TakeDamage(int amount)
{
if (!isServer)
return;
currentHealth -= amount;
if (currentHealth <= 0)
{
currentHealth = maxHealth;
// called on the Server, but invoked on the Clients
RpcRespawn();
}
}
void OnChangeHealth (int currentHealth )
{
healthBar.sizeDelta = new Vector2(currentHealth , healthBar.sizeDelta.y);
}
[ClientRpc]
void RpcRespawn()
{
if (isLocalPlayer)
{
// move back to zero location
transform.position = Vector3.zero;
}
}
}
在我们的示例中,客户端能够操纵本地Player对象,这是因为Player在客户端拥有本地权限(local authority)。如果服务器简单地对Player进行复位,那么客户端会盖过服务器,因为Player的操作权限在客户端手上。为了避免这种情况,服务器通过ClientRpc方法对客户端发出指令,让客户端对Player进行复位。之后,由于NetworkTransform的作用,Player的位置信息会在网络上同步。
现在你可以进行测试了。现在HP归零的Player会在出生点满血复活。
到目前为止,我们的示例一直关注于玩家控制的对象。然而,许多游戏中都存在一些非玩家控制的对象。这一节中,我们会专注于开发一些类似敌人的游戏对象。
我们已经知道,玩家控制的Player对象是在客户端连接上Host后生成的,它由玩家控制。与此相反,敌人对象全都是由服务器控制的。
这一节中,我们会创建一个敌人生成器(Enemy Spawner),它能够生成非玩家控制的敌人对象,这些对象可以被任意一个玩家攻击与杀死。
- 创建一个空的GameObject。
- 重命名为”Enemy Spawner”。
- 选中Enemy Spawner。
- add component: Network > NetworkIdentity。
- 在Inspector视图的NetworkIdentity中,将Server Only设置为true。
将Server Only设置为true能够防止Enemy Spawner被发送到客户端。
- 选中Enemy Spawner。
- 创建一个新的脚本,并取名为EnemySpawner。
- 打开脚本编辑器。
- 用下面的代码替换掉原来的代码:
using UnityEngine;
using UnityEngine.Networking;
public class EnemySpawner : NetworkBehaviour {
public GameObject enemyPrefab;
public int numberOfEnemies;
public override void OnStartServer()
{
for (int i=0; i < numberOfEnemies; i++)
{
var spawnPosition = new Vector3(
Random.Range(-8.0f, 8.0f),
0.0f,
Random.Range(-8.0f, 8.0f));
var spawnRotation = Quaternion.Euler(
0.0f,
Random.Range(0,180),
0.0f);
var enemy = (GameObject)Instantiate(enemyPrefab, spawnPosition, spawnRotation);
NetworkServer.Spawn(enemy);
}
}
}
关于上面这段代码,有几个注意点:
- 需要添加UnityEngine.Networking命名空间。
- 类需要继承自NetworkBehaviour。
- 类覆盖了一个OnStartServer方法。
- 当服务器启动,它会创建一组拥有随机的初始位置和朝向的敌人。之后,它们会通过NetworkServer.Spawn(enemy)生成。
OnStartServer方法和前面用来为本地Player上色的OnStartLocalPlayer方法很像。OnStartServer是在服务器开始监听网络时调用。NetworkBehaviour类中还有许多能够被覆盖的方法,详情请查询文档。
接下来保存脚本并返回Unity。
现在Enemy Spawner已经创建完毕了,我们需要一个用于生成的敌人对象。为了尽可能地加快速度,我们会对Player预制件进行一些简单的改动,让他成为一个Enemy。事实上,Enemy和Player有许多共同之处,比如都需要NetworkIdentity和NetworkTransform,都需要生命值系统和血槽等。
- 把Player预制件拖进Hierarchy视图。
- 将其改名为Enemy。
- 再将Enemy拖回Project视图以创建一个Enemy预制件。
这么做的目的是防止我们对Enemy的改动影响到Player。
- 删除Enemy的Gun子对象。
Unity会警告我们这是一个会破坏预制件的行为。
- 点击continue。
- 选中Enemy。
- 删除Bullet Spawn子对象。
- 在Inspector视图中,移除PlayerController脚本组件。
现在,这个Enemy已经可以准备上路了。不过,它和Player看起来太相似了,我们再做一些修改,让它变得和Player不同。
- 对Enemy应用Black Material。
- 设置Visor的Material为Default-Material(可以创建一个新的Material并应用在Visor上)。
- 选中Enemy。
- 创建一个子Cube对象,并重命名为”Mohawk”。
- 将Position改为(0.0. 0.55, -0.15)。
- 将Scale改为(0.2, 1.0, 1.0)。
- 删除Mohawk的BoxCollider组件。
最后,Enemy看起来会是这样:
- 将改动保存到Eneny预制件。
- 从Scene中删除Enemy。
- 保存。
最后,我们需要注册Enemy预制件,并将其引用赋给Enemy Spawner。
- 在Hierarchy视图中选中NetworkManager。
- 打开Spawn Info标签。
- 在Registered Spawnable Prefabs列表中添加一行。
- 添加Enemy预制件。
- 选中Enemy Spawner。
- 将Enemy预制件赋给Enemy Spawner的Enemy Prefab属性。
- 设置敌人数量为4。
- 保存。
现在你可以开始测试了。不出意外的话,你应当能够看到几个随机出现的敌人,并且可以射击它们。问题在于,即便把它们的HP打到0,它们不仅不会消失,而且HP会回到满。这是因为复位功能在RpcRespawn方法中,而Enemy对象无法通过isLocalPlayer判定,因此不会复位。而回复HP的功能在TakeDamage方法中,这个方法是服务器控制的。
我们需要进行一些改动,让Enemy在HP归零时被摧毁。最简单的实现方法是对Health脚本进行一些修改,让它把Player和Enemy区分开。
- 打开Health脚本。
- 添加一个public的bool域destroyOnDeath。
public bool destroyOnDeath;
if (destroyOnDeath)
{
Destroy(gameObject);
}
else
{
// existing Respawn code
}
最终的Health脚本如下:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;
public class Health : NetworkBehaviour {
public const int maxHealth = 100;
public bool destroyOnDeath;
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public RectTransform healthBar;
public void TakeDamage(int amount)
{
if (!isServer)
return;
currentHealth -= amount;
if (currentHealth <= 0)
{
if (destroyOnDeath)
{
Destroy(gameObject);
}
else
{
currentHealth = maxHealth;
// called on the Server, will be invoked on the Clients
RpcRespawn();
}
}
}
void OnChangeHealth (int currentHealth)
{
healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
}
[ClientRpc]
void RpcRespawn()
{ if (isLocalPlayer)
{
// Set the player’s position to origin
transform.position = Vector3.zero;
}
}
}
现在你可以进行测试了。射击敌人应当能够正确地削减其生命值,当HP归零时敌人会被摧毁。Player的行为保持不变。
目前,每个玩家的出生点和复活点都在原点。在游戏的一开始,除非我们移动一个Player,否则它们会重叠在一起。理想情况下,玩家们应当在不同的地方出生。NetworkStartPosition能够帮助我们实现这个功能,它是Unity系统提供的一个专门用来处理出生点问题的组件。
为了创建两个不同的出生点,我们需要创建两个新的GameObject,并为它们各自添加一个NetworkStartPosition组件。
- 创建一个空的Object。
- 将其重命名为”Spawn Position 1”。
- 选中Spawn Position 1。
- add component Network > NetworkStartPosition。
- 将Position设置为(3, 0, 0)。
- 拷贝一个Spawn Position 1。
- 将新的改名为Spawn Position 2。
- 选中Spawn Position 2。
- 将Position设置为(-3, 0, 0)。
- 选中Hierarchy视图中的NetworkManager。
- 打开Spawn Info标签。
- 将Player Spawn Method改为Round Robin。
因为Spawn Position拥有NetworkStartPosition组件,NetworkManager会自动找到它们。之后,NetworkManager会用它们的Transform信息来为新加入的客户端分配一个出生点。
前面提到了Player Spawn Method,这是一种确定出生点的方法,有两种:Random and Round Robin。顾名思义,Random会从可用的NetworkSpawnPosition中随机选择一个,而Round Robin(轮询算法)会在可用的出生点间进行循环。如果使用Random,那么多个玩家可能会分配到同一个出生点;如果使用Round Robin,那么除非玩家数大于出生点数,否则它们绝对不会出生在同一个位置。
现在你可以进行测试了。你应当能看到两个Player出生在不同的位置。
现在只剩下最后一步了。现在玩家虽然能够在不同的地方出生,但是他们被打爆之后复位的地方却始终是在原点。我们需要建立一个简单的系统,利用NetworkStartPosition组件来创建一个出生点数组,以此来改变复活点。这一步虽然不是必须的,但这能够让这个示例显得更加完整。
值得注意的是,有一个更简单的方法可以存储每名玩家的出生点位置,那就是在Start方法中记录下由轮询算法分配的出生点位置,并用这个作为玩家的复活点。
现在我们需要创建一个数组,找到所有拥有NetworkStartPosition组件的GameObject,并将它们加入数组中,并以它们的Transform作为出生点。这和NetworkManager做的工作非常相似。不过这里我们就不需要实现轮询算法了,直接用随机算法就好。
- 打开Health脚本。
- 添加一个数组用于存储出生点:
private NetworkStartPosition[] spawnPoints;
spawnPoints = FindObjectsOfType();
注意这里使用了复数形式的版本:FindObjectsOfType。
- 在RpcRespawn方法中,删除原本用来重设Player位置的代码,并用下面的代码代替:
// Set the spawn point to origin as a default value
Vector3 spawnPoint = Vector3.zero;
// If there is a spawn point array and the array is not empty, pick a spawn point at random
if (spawnPoints != null && spawnPoints.Length > 0)
{
spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position;
}
// Set the player’s position to the chosen spawn point
transform.position = spawnPoint;
最终的Health脚本:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;
public class Health : NetworkBehaviour {
public const int maxHealth = 100;
public bool destroyOnDeath;
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public RectTransform healthBar;
private NetworkStartPosition[] spawnPoints;
void Start ()
{
if (isLocalPlayer)
{
spawnPoints = FindObjectsOfType();
}
}
public void TakeDamage(int amount)
{
if (!isServer)
return;
currentHealth -= amount;
if (currentHealth <= 0)
{
if (destroyOnDeath)
{
Destroy(gameObject);
}
else
{
currentHealth = maxHealth;
// called on the Server, invoked on the Clients
RpcRespawn();
}
}
}
void OnChangeHealth (int currentHealth )
{
healthBar.sizeDelta = new Vector2(currentHealth , healthBar.sizeDelta.y);
}
[ClientRpc]
void RpcRespawn()
{
if (isLocalPlayer)
{
// Set the spawn point to origin as a default value
Vector3 spawnPoint = Vector3.zero;
// If there is a spawn point array and the array is not empty, pick one at random
if (spawnPoints != null && spawnPoints.Length > 0)
{
spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position;
}
// Set the player’s position to the chosen spawn point
transform.position = spawnPoint;
}
}
}
现在你可以进行测试了。玩家应当不会一直在原点复活了。
通过这个例子,你已经了解到了创建一个多人联机游戏所需的基础概念和组件。
我们讲解了HLAPI的概念与主要的使用方法。当我们使用HLAPI时,服务器和所有客户端都在运行同样的脚本中的同样的代码。我们还讲述了如何让客户端和服务器的逻辑分流,通过isLocalPlayer、isServer等判定条件。
我们讲解了HLAPI中实现Rpc的两种方式:Command与ClientRpc。Command是在客户端调用而在服务器端执行的方法,ClientRpc是在服务器端调用,而在客户端执行的方法。
我们讲解了SyncVar与SyncVar hook。为变量赋予[SyncVar]特性,当变量改变时SyncVar hooks方法会自动被调用。
我们讲解了如何通过NetworkIdentity与NetworkTransform实现各个客户端上的Player的同步。
我们讲解了许多可用的网络组件,包括NetworkManager、NetworkManagerHUD与NetworkStartPosition。除了这些之外,还有更多的组件可供使用。它们的功能各异,你可以查找文档或是我们的其他课程来学习它们。
我们希望这门课程能够作为你在Unity中开发联机游戏的一个好的起点。