UNet開發多人聯機射擊遊戲
引言: Networking作爲Unity官方的用於開發多人在線遊戲的網絡模塊,開發者可以不用自己搭建網絡模塊的底層,通過使用Unity提供的一些相關組件,可以輕鬆實現簡單的多人在線遊戲。本片博客爲泰課在線賈老師的《Unity多人網絡系統講解》的學習筆記,鏈接地址在文末。
開發版本: Unity 2017.2
文章目錄
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多人網絡系統講解-實踐篇
Unity3D網絡組件UNet詳解
Networking API文檔翻譯