1. 程式人生 > >Unity Networking開發多人聯機射擊遊戲

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文件翻譯