引言: Networking作为Unity官方的用于开发多人在线游戏的网络模块,开发者可以不用自己搭建网络模块的底层,通过使用Unity提供的一些相关组件,可以轻松实现简单的多人在线游戏。本片博客为泰课在线贾老师的《Unity多人网络系统讲解》的学习笔记,链接地址在文末。
开发版本: Unity 2017.2
创建空对象,添加Network Manager和Network Manager HUD组件,如下图所示:
玩家可以分为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用来表示他们是在不同机器上的同一网络对象。
将Player预制体添加到Network Manager组件中的Player Prefab中,并将场景中的Player删除,如下所示:
运行游戏,点击左上角的LAN Host按钮,将其作为服务器,又作为客户端使用,如下所示:
然后,Network Manager会自动在原点生成一个LocalPlayer,左上角表示客户端连接的IP为本地IP,端口号为7777
为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按钮,效果如下所示:
我们发现如下问题:
修改代码如下,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)表示网络数据同步的频率,如果同步频率太频繁会导致网络延迟等问题,而频率太低又会影响用户的体验。
为PlayerController脚本添加如下方法
//用于本地玩家初始化
public override void OnStartLocalPlayer()
{
MeshRenderer[] renderers = gameObject.GetComponentsInChildren();
foreach (var render in renderers)
{
render.material.color = Color.blue;
}
}
创建一个球体,根据坦克炮筒口径,调整大小,勾选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();
foreach (var render in renderers)
{
render.material.color = Color.blue;
}
}
private void Fire()
{
GameObject bullet = (GameObject)Instantiate(bulletPrefab, bulletSpawnPos.position, bulletSpawnPos.rotation);
bullet.GetComponent().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().velocity = bullet.transform.forward * 20;
NetworkServer.Spawn(bullet);
Destroy(bullet, 2);
}
Command:在客户端调用,服务器端执行。客户端调用的参数必须要UNet可以序列化,这样服务器在执行时才能把参数反序列化。需要注意,在客户端需要有权限的NetworkIdentity组件才能调用Command命令。
NetworkServer:主要持有一个NetworkScene并且做一些只有在服务器上才能对网络服务做的事,如spawn, destory等。以及维护所有客户端连接。
为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();
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);
}
}
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;
}
}
服务器端生成非玩家对象,首先创建一个空对象,命名为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中。运行后如下:
创建空的预制体,添加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();
}
}
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多人网络系统讲解-实践篇
Unity3D网络组件UNet详解
Networking API文档翻译