<>UNet开发多人联机射击游戏

引言:
Networking作为Unity官方的用于开发多人在线游戏的网络模块,开发者可以不用自己搭建网络模块的底层,通过使用Unity提供的一些相关组件,可以轻松实现简单的多人在线游戏。本片博客为泰课在线贾老师的《Unity多人网络系统讲解》的学习笔记,链接地址在文末。
开发版本: Unity 2017.2


文章目录

* UNet开发多人联机射击游戏
<https://blog.csdn.net/qq_35361471/article/details/83957817#UNet_0>
* 1. 网络管理器 <https://blog.csdn.net/qq_35361471/article/details/83957817#1__4>
* 2. 创建Player预制体
<https://blog.csdn.net/qq_35361471/article/details/83957817#2_Player_8>
* 3. 注册Player
<https://blog.csdn.net/qq_35361471/article/details/83957817#3_Player_18>
* 4. 控制玩家移动 <https://blog.csdn.net/qq_35361471/article/details/83957817#4__26>
* 5. 初始化LocalPlayer颜色
<https://blog.csdn.net/qq_35361471/article/details/83957817#5_LocalPlayer_70>
* 6. 添加射击功能 <https://blog.csdn.net/qq_35361471/article/details/83957817#6__85>
* 7. 显示玩家生命值
<https://blog.csdn.net/qq_35361471/article/details/83957817#7__160>
* 8. 处理死亡 <https://blog.csdn.net/qq_35361471/article/details/83957817#8__245>
* 9. 添加敌人 <https://blog.csdn.net/qq_35361471/article/details/83957817#9__290>
* 10. 修改出生位置
<https://blog.csdn.net/qq_35361471/article/details/83957817#10__315>


<>1. 网络管理器

创建空对象,添加Network Manager和Network Manager HUD组件,如下图所示:


<>2. 创建Player预制体

玩家可以分为LocalPlayer和RemotePlayer:
LocalPlayer指本地玩家控制的对象
RemotePlayer指多人游戏中其他玩家控制的对象
为提供的坦克Player添加Network Identity组件,勾选Local Player
Authority,表示该对象由本地玩家控制,而不是服务器。并将该对象制作为预制体。





Network
Identity:网络物体最基本的组件,客户端与服务器确认是否是一个物体(netID),也用来表示各个状态,比如判断是否是服务器,是否是客户端,是否有权限,是否是本地玩家等。举一个简单的栗子,A是Host(又是服务器,又是客户端),B是一个Client(客户端),A与B分别有一个玩家PlayA与PlayB。在机器A上,playA与playB的isServer为true,isClent为true,其中playA有权限,是本地玩家,B没权限,也不是本地玩家。在机器B上,playA与playB的isServer为false,isClent为true,其中playB有权限,是本地玩家,A没权限,也不是本地玩家。机器A与机器B上的PlayA的netID相同,机器A与机器B上的PlayB的netID也相同,其中netID用来表示他们是在不同机器上的同一网络对象。

<>3. 注册Player

将Player预制体添加到Network Manager组件中的Player Prefab中,并将场景中的Player删除,如下所示:

运行游戏,点击左上角的LAN Host按钮,将其作为服务器,又作为客户端使用,如下所示:

然后,Network Manager会自动在原点生成一个LocalPlayer,左上角表示客户端连接的IP为本地IP,端口号为7777


<>4. 控制玩家移动

为Player添加脚本PlayerController,可以实现WASD键或者方向键控制塔克移动旋转,脚本如下:
public float rotateSpeed = 150; public float moveSpeed = 6; private void
Update() { var x = Input.GetAxis("Horizontal") * Time.deltaTime * rotateSpeed;
var z = Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed;
transform.Rotate(0, x, 0); transform.Translate(0, 0, z); }
打包一个PC端用于测试多人在线,编辑器点击LAN Host,打包的点击LAN Client按钮,效果如下所示:

我们发现如下问题:

* 无论在Host端或者Client端,进行移动或者旋转操作,两个Player都会有响应。
* 一方有位移或者角度变化,并一方不会保持相同变化
修改代码如下,isLocalPlayer用于判断是否是本地玩家,只有本地玩家才可以做出响应
using UnityEngine.Networking; public class PlayerController : NetworkBehaviour
{ public float rotateSpeed = 150; public float moveSpeed = 6; private void
Update() { if (isLocalPlayer == false) return; var x =
Input.GetAxis("Horizontal") * Time.deltaTime * rotateSpeed; var z =
Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed; transform.Rotate(0, x,
0); transform.Translate(0, 0, z); } }
为Player添加Network Transform组件,用于网络间同步Transform数据,其中Network Send
Rate(Seconds)表示网络数据同步的频率,如果同步频率太频繁会导致网络延迟等问题,而频率太低又会影响用户的体验。


<>5. 初始化LocalPlayer颜色

为PlayerController脚本添加如下方法
//用于本地玩家初始化 public override void OnStartLocalPlayer() { MeshRenderer[]
renderers = gameObject.GetComponentsInChildren<MeshRenderer>(); foreach (var
render in renderers) { render.material.color = Color.blue; } }


<>6. 添加射击功能

创建一个球体,根据坦克炮筒口径,调整大小,勾选Collider的isTrigger,为其添加Rigidbody组件,并取消勾选UseGravity。添加
NetworkIdentity、NetworkTransform
组件,将NetworkSendRate调整为0,因为在子弹生成的时候,我们规定了其位置和发射方向,可以由本地计算子弹接下来的位置,而不用网络同步来调整子弹位置,可以减少网络同步数据的压力。最后,将其作为预制体保存。

为PlayerController添加发射子弹的方法
using UnityEngine.Networking; public class PlayerController : NetworkBehaviour
{ public float rotateSpeed = 150; public float moveSpeed = 6; public GameObject
bulletPrefab; public Transform bulletSpawnPos; private void Update() { if
(isLocalPlayer == false) return; var x = Input.GetAxis("Horizontal") *
Time.deltaTime * rotateSpeed; var z = Input.GetAxis("Vertical") *
Time.deltaTime * moveSpeed; transform.Rotate(0, x, 0); transform.Translate(0,
0, z); if (Input.GetKeyDown(KeyCode.Space)) { Fire(); } } //用于本地玩家初始化 public
override void OnStartLocalPlayer() { MeshRenderer[] renderers =
gameObject.GetComponentsInChildren<MeshRenderer>(); foreach (var render in
renderers) { render.material.color = Color.blue; } } private void Fire() {
GameObject bullet = (GameObject)Instantiate(bulletPrefab,
bulletSpawnPos.position, bulletSpawnPos.rotation);
bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 20;
Destroy(bullet, 2); } }
在坦克炮口位置创建一个空物体,作为子弹生成的位置


将子弹预制体和BulletSpawnPos对象赋值到PlayerController上,如下所示:


此时,打包测试,会发现一方发射子弹,另一方不会同步,如下所示:

解决该问题,需要先将子弹在Network Manager中注册为可生成预制体,如下:


然后将Fire方法修改为Command方法,并且将生成的Bullet对象,放到服务器的管理生成对象的集合中,如果后面有个客户端连接进来,可以保证生成的预制体一致。
[Command] private void CmdFire() { GameObject bullet =
(GameObject)Instantiate(bulletPrefab, bulletSpawnPos.position,
bulletSpawnPos.rotation); bullet.GetComponent<Rigidbody>().velocity =
bullet.transform.forward * 20; NetworkServer.Spawn(bullet); Destroy(bullet, 2);
}

Command:在客户端调用,服务器端执行。客户端调用的参数必须要UNet可以序列化,这样服务器在执行时才能把参数反序列化。需要注意,在客户端需要有权限的NetworkIdentity组件才能调用Command命令。
NetworkServer:主要持有一个NetworkScene并且做一些只有在服务器上才能对网络服务做的事,如spawn,
destory等。以及维护所有客户端连接。

打包测试效果如下:


<>7. 显示玩家生命值

为Player添加Helath脚本
public class Health : MonoBehaviour { public const int maxHealth = 100; public
int currentHealth = maxHealth; public RectTransform bloodNum; public void
TakeDamage(int count) { currentHealth -= count; if (currentHealth <= 0) {
currentHealth = 0; } bloodNum.sizeDelta = new Vector2(currentHealth,
bloodNum.sizeDelta.y); } }
为bullet添加Bullet的脚本
public class Bullet : MonoBehaviour { private void OnTriggerEnter(Collider
other) { Health health = other.gameObject.GetComponent<Health>(); if (health !=
null) health.TakeDamage(10); Destroy(gameObject); } }
创建血条UI,设置为World Space模式,如下:

需要将BloodNum图片的锚点设置在左侧,然后将其赋值给Health中的bloodNum,如下:

为了让HealthBar永远朝向摄像机,添加BillBoard脚本
public class BillBoard : MonoBehaviour { void Update () {
transform.LookAt(Camera.main.transform); } }

经打包测试,发现已经可以子弹打中后掉血的功能,但目前掉血是由于两方的子弹打中坦克后,都触发TakeDamage方法。如果一方的子弹已经打中对方并销毁,由于网络延迟,另一方的子弹还没打中对象,由于子弹是服务器统一管理,所以子弹还没打中对象就直接销毁子弹了,这样就会导致两方的数据不一致现象。

如何解决这个问题呢,需要使用SyncVar特性

SyncVar:服务器的值能自动同步到客户端,保持客户端的值与服务器一致。客户端值改变并不会影响服务器的值。


修改Health脚本,TakeDamage方法只在服务器执行,即数据逻辑在服务器处理,其他客户端的数据均以服务器为准,当currentHealth的值发生变化时,自动同步到所有客户端,并调用OnChangeHealth方法,currentHealth作为方法形参传入。
using UnityEngine.Networking; public class Health : NetworkBehaviour { public
const int maxHealth = 100; public RectTransform bloodNum; [SyncVar(hook =
"OnChangeHealth")] public int currentHealth = maxHealth; public void
TakeDamage(int count) { if (isServer == false) return; currentHealth -= count;
if (currentHealth <= 0) { currentHealth = 0; } } public void OnChangeHealth(int
currentHealth) { bloodNum.sizeDelta = new Vector2(currentHealth,
bloodNum.sizeDelta.y); } }
打包测试,血条可以正常同步,如下所示:


<>8. 处理死亡


ClientRpc:服务端调用,客户端执行。服务端的参数序列化到客户端执行,一般来说,服务端会找到上面的NetworkIdentity组件,确定那些客户端在监视这个NetworkIdentity,Rpc命令会发送给所有的监视客户端。注意方法名要以“Rpc”开头。
using UnityEngine.Networking; public class Health : NetworkBehaviour { public
const int maxHealth = 100; public RectTransform bloodNum; public bool
destroyOnDeath; [SyncVar(hook = "OnChangeHealth")] public int currentHealth =
maxHealth; public void TakeDamage(int count) { if (isServer == false) return;
currentHealth -= count; if (currentHealth <= 0) { if (destroyOnDeath) {
Destroy(gameObject); }else { currentHealth = maxHealth; RpcRespawn(); } } }
public void OnChangeHealth(int currentHealth) { bloodNum.sizeDelta = new
Vector2(currentHealth, bloodNum.sizeDelta.y); } [ClientRpc] private void
RpcRespawn() { if (isLocalPlayer) transform.position = Vector3.zero; } }
<>9. 添加敌人

服务器端生成非玩家对象,首先创建一个空对象,命名为EnemySpawner,添加NetworkIdentity组件,勾选Server
Only,添加EnemySpawner脚本。

public class EnemySpawner : NetworkBehaviour { public GameObject enemyPrefab;
public int numOfEnemy; //用于服务器的初始化操作 public override void OnStartServer() { for
(int i = 0; i < numOfEnemy; i++) { Vector3 spawnPos = new
Vector3(Random.Range(-15, 15), 0, Random.Range(-15, 15)); Quaternion
spawnRotation = Quaternion.Euler(0, Random.Range(0, 180), 0); GameObject enemy
= (GameObject)Instantiate(enemyPrefab, spawnPos, spawnRotation);
NetworkServer.Spawn(enemy); } } }

复制一个Player预制体,修改为Enemy预制体,并删除PlayerController组件,需要勾选Health组件中的DestroyOnDeath。然后将其注册到NetworkManager中的RegisteredSpawnablePrefabs中。运行后如下:


<>10. 修改出生位置

创建空的预制体,添加Network Start Position组件

将Network Manager中的Player Spawn Method修改为Round Robin,表示按生成点顺序一个一个生成


修改Health脚本,修改其生成位置
using UnityEngine.Networking; public class Health : NetworkBehaviour { public
const int maxHealth = 100; public RectTransform bloodNum; public bool
destroyOnDeath; [SyncVar(hook = "OnChangeHealth")] public int currentHealth =
maxHealth; private NetworkStartPosition[] spawnPoints; private void Start() {
OnChangeHealth(currentHealth); if (isLocalPlayer) { spawnPoints =
FindObjectsOfType<NetworkStartPosition>(); } } public void TakeDamage(int
count) { if (isServer == false) return; currentHealth -= count; if
(currentHealth <= 0) { if (destroyOnDeath) { Destroy(gameObject); }else {
currentHealth = maxHealth; RpcRespawn(); } } } public void OnChangeHealth(int
currentHealth) { bloodNum.sizeDelta = new Vector2(currentHealth,
bloodNum.sizeDelta.y); } [ClientRpc] private void RpcRespawn() { if
(isLocalPlayer) { Vector3 spawnPoint = Vector3.zero; if (spawnPoints != null &&
spawnPoints.Length > 0) { spawnPoint = spawnPoints[Random.Range(0,
spawnPoints.Length)].transform.position; } transform.position = spawnPoint; } }
}
打包测试,实现了修改生成位置的功能。

自此,简单的多人在线射击游戏开发完成,每天学习一点,至少比昨天的自己进步了一点!

参考资源:
  Unity多人网络系统讲解-实践篇 <http://www.taikr.com/my/course/1018>
  Unity3D网络组件UNet详解 <https://www.jianshu.com/p/6ab52fc25ecc>
  Networking API文档翻译
<https://www.cnblogs.com/wangergo/p/5021310.html?mType=Group>