Unity Networking開發多人聯機射擊遊戲

UNet開發多人聯機射擊遊戲

引言: Networking作爲Unity官方的用於開發多人在線遊戲的網絡模塊,開發者可以不用自己搭建網絡模塊的底層,通過使用Unity提供的一些相關組件,可以輕鬆實現簡單的多人在線遊戲。本片博客爲泰課在線賈老師的《Unity多人網絡系統講解》的學習筆記,鏈接地址在文末。
開發版本: Unity 2017.2

1. 網絡管理器

創建空對象,添加Network Manager和Network Manager HUD組件,如下圖所示:
Network Manager

2. 創建Player預製體

玩家可以分爲LocalPlayer和RemotePlayer:
LocalPlayer指本地玩家控制的對象
RemotePlayer指多人遊戲中其他玩家控制的對象
爲提供的坦克Player添加Network Identity組件,勾選Local Player Authority,表示該對象由本地玩家控制,而不是服務器。並將該對象製作爲預製體。


Network Identity
演示

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刪除,如下所示:
註冊Player
運行遊戲,點擊左上角的LAN Host按鈕,將其作爲服務器,又作爲客戶端使用,如下所示:
LAN Host
然後,Network Manager會自動在原點生成一個LocalPlayer,左上角表示客戶端連接的IP爲本地IP,端口號爲7777
Network Manager

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)表示網絡數據同步的頻率,如果同步頻率太頻繁會導致網絡延遲等問題,而頻率太低又會影響用戶的體驗。
Network Transform

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。添加NetworkIdentityNetworkTransform組件,將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模式,如下:
血條UI設置
需要將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中。運行後如下:
生成Enemy

10. 修改出生位置

創建空的預製體,添加Network Start Position組件
NetworkStartPosition
將Network Manager中的Player Spawn Method修改爲Round Robin,表示按生成點順序一個一個生成
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文檔翻譯

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章