1. 程式人生 > >Unity官方教程 聯機部分翻譯

Unity官方教程 聯機部分翻譯

(1)一個簡單的聯機示例

多人聯機一直是一項細節性強且複雜的工作,包含很多細粒度的問題,比如如何讓位於世界各地的各類裝置實現資料同步以及交流。通過Unity的內嵌的多人聯機系統以及HLAPI(High Level API),我們希望能夠為這個問題提供一個簡單的解決方案。
通過這個簡單的聯機示例,我們會展示如何從零開始,使用最簡單的指令碼和資源搭建一個多人聯機專案。我們希望你能夠在讀完這篇文件之後,能夠迅速掌握我們的多人聯機系統與HLAPI的使用方法。
這篇文件會手把手地展示如何使用Unity內嵌的多人聯機系統和HLAPI搭建一個多人聯機專案。我們設計的每一步不僅泛用性強,而且包含許多關於多人聯機的重要概念。開發者能夠根據自己的需要擴充套件這些步驟以適應不同型別的遊戲。當專案開發完畢後,它將能夠支援兩名玩家在兩個不同的專案例項上獨立地控制自己的角色,伺服器會負責角色行為的控制和同步。玩家之間能夠互相射擊,也可以射擊其他敵人。當玩家被擊敗時,他控制的遊戲角色會復位。
這篇文件適合中級開發者閱讀。我們希望開發者能夠能夠先閱讀一下我們的多人聯機開發手冊,特別是

Networking Overview部分以及The High Level API以及它們的子頁,包括Network System Concepts
開始之前,請先:
- 建立一個空的Unity3D專案。
- 將預設的scene儲存為”Main”。

Unity網路功能概覽(Networking Overview)

使用聯網特性的開發者大致可以分為兩類:
- 使用Unity開發聯機遊戲的開發者。這類開發者應當首先閱讀NetworkManager部分或是High Level API部分。
- 搭建網路基礎部分以及開發高階聯機遊戲的開發者。這類開發者應當首先閱讀NetworkTransport API部分。

高階指令碼API(High level scripting API)

Unity的聯網系統有一組高階指令碼API(HLAPI)。這些方法基本上能覆蓋絕大部分的多人遊戲的共同需求。使用這些API,你可以忽略底層細節,專注於功能的開發。簡單來講,這些API能夠:
- 使用NetworkManager控制遊戲的連線狀態。
- 管理”客戶端主機”遊戲,這類遊戲中的主機由一個玩家客戶端扮演。
- 使用一個多功能的serializer序列化資料。
- 傳送以及接收網路訊息。
- 從客戶端向伺服器端傳送指令。
- 實現客戶端到伺服器端的遠端過程呼叫(RPC)。
- 從客戶端向伺服器端傳送網路事件。

與編輯器和引擎的結合

Unity的網路系統嵌入到了它的編輯器和引擎中,這讓網路遊戲的開發變得視覺化。它提供了:
- NetworkIdentity,用於需要聯網的元件。
- NetworkBehaviour,用於聯機指令碼。
- 可配置的物件變化自動同步。
- 指令碼變數自動同步。
- 在Unity Scene中放置網路構件。
- Network components

網路服務

Unity提供了網路服務來為你的遊戲開發提供便利,包括以下功能:
- 比賽匹配。
- 建立比賽以及通告比賽。
- 顯示可用的比賽以及加入。
- 中繼伺服器(Relay Server)。
- 無伺服器的聯網對局。
- 向比賽參與者傳送訊息。

網路傳輸以及實時傳輸層(real-time transport layer)

Unity提供了實時傳輸層(real-time transport layer),提供了:
- 最優化的基於UDP的傳輸協議。
- 多通道設計,用於避免隊頭訊息阻塞。
- 支援設定每個通道的服務質量(QoS)。
- 靈活的網路拓撲結構,支援端到端以及客戶端-伺服器結構。

示例專案

(2)網路管理器(Network Manager)

這節課中,我們將建立一個新的Network Manager物件。這個Network Manager物件會控制這個多人聯機專案的狀態資訊,包括遊戲狀態管理,場景管理,比賽建立,並且支援訪問Debug資訊。高階開發者還能夠擴充套件NetworkManager類來自定義元件的行為,不過這部分內容不會包含在這節課中。
想要建立一個新的Network Manager物件,我們需要建立一個新的GameObject,併為其加上NetworkManager與NetworkManagerHUD元件(Component):
- 建立一個空的Object;
- 將其重新命名為”Network Manager”;
- 選中這個Object;
- 新增元件(Add Component):Network > NetworkManager;
- 新增元件(Add Component):Network > NetworkManagerHUD;

(3)建立Player預製件(Prefab)

在這個專案中,player預製件用於代表玩家們。
預設情況下,NetworkManager會通過克隆player預製件並生成到遊戲中來為每個連線進遊戲的玩家例項化一個遊戲物件。
網路生成(Network Spawning)以及在客戶端和伺服器上同步遊戲物件的細節會在後面的課程中介紹。
這裡,玩家的GameObject會是一個簡單的膠囊體,上面附著一個”臉”,用來告訴我們這個膠囊體的朝向。
完成後的GameObject會是這樣:

要建立這個GameObject,你需要:
- 建立一個Capsule。
- 將其重新命名為”Player”。

為了指示出這個物件的“前方”,為它新增一個子立方體,並將顏色設定為黑色:
- 選中Player。
- 建立一個立方體,並將其設定為Player的子物體。
- 將其重新命名為”Visor”。
- 設定它的Scale為(0.95, 0.25, 0.5)。
- 設定它的Position為 (0.0, 0.5, 0.24)。
- 建立一個新的Material。
- 將其重新命名為”Black”。
- 選中Black。
- 將它的Albedo color改為黑色。
- 將Visor的Material設定為Black。簡單的方法是直接把Material拖到Scene檢視的Visor上。

為了將Player標識為一個特殊的聯網的遊戲物件,為Player新增一個NetworkIdentity元件:
- 選中Player。
- 新增元件(Add Component):Network > NetworkIdentity。

NetworkIdentity元件用來在網路上識別這個物體,並讓網路系統意識到它。
- 將 Local Player Authority 設定為true。


將Player的NetworkIdentity設定為Local Player Authority會允許客戶端控制Player的移動。
接下來由Player建立一個預製件:
- 把Player從Hierarchy檢視拖到Project檢視來建立一個新的prefab資源。
- 從場景中刪除Player。
- 儲存場景。

(4)註冊Player預製件

當Player預製件建立完畢後,我們需要對其進行註冊。Network Manager會用這個預製件】來生成新的玩家控制的物件,並置入場景中。
- 在Hierarchy檢視中選中之前建立的Network Manager。
- 在Inspector檢視中開啟Spawn Info標籤。
- 把Player預製件拖進Player Prefab域中。


NetworkManager元件被用來控制聯機物件的生成,包括Player。在許多遊戲中,玩家都會有一個歸自己控制的標誌性的物件。NetworkManager有一個專門的域,用來存放用於代表玩家的Player預製件。每個進入遊戲的玩家客戶端都會得到一個新建立的遊戲物件

(5)讓Player動起來

接下來我們會製作遊戲的第一個功能特性:在場景中移動Player。為此,我們會編寫一個新的指令碼,叫做“PlayerController”。
首先編寫最簡單的程式碼部分,這部分不會涉及到聯網功能,僅僅在單一玩家環境下工作。
- 為Player預製件(prefab)建立一個新的指令碼,命名為”PlayerController”。
- 開啟指令碼編輯器。
- 寫入如下程式碼:

    using UnityEngine;

    public class PlayerController : MonoBehaviour
    {
        void Update()
        {
            var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
            var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;

            transform.Rotate(0, x, 0);
            transform.Translate(0, 0, z);
        }
    }

這個簡單的指令碼能實現移動人物與轉向的功能。預設情況下Input.GetAxis("Horizontal")Input.GetAxis("Vertical")允許玩家通過WASD與方向鍵乃至觸控面板來控制玩家。如果想要改變鍵位,請查詢Input Manager相關內容(Edit-Project Settings-Input)。
接下來:
- 儲存指令碼。
- 回到Unity。
- 儲存場景。

(6)對Player進行線上測試

以Host的身份進行測試

現在,Player還只能夠在客戶端上移動,沒有聯網功能。
想要進行聯網測試:
- 進入Play模式。

在Play模式中,NetworkManagerHUD預設顯示如下:

- 單擊LAN Host按鈕,這能夠讓你以主機的身份啟動遊戲。

NetworkManager會用Player預製件建立一個新的Player,NetworkManagerHUD會改變顯示來表明伺服器目前處於活動狀態。
這種情況下,遊戲以”Host”模式執行。伺服器和客戶端處於同一個程序中。
接下來:
- 用WASD來控制Player。
- 單擊Stop按鈕來斷開連線。
- 退出Player模式。

以客戶端的身份進行測試

想要以客戶端的身份進行測試,我們需要兩個同時執行的遊戲例項,其中一個扮演Host。一個可以從編輯器中開啟,而另一個就必須首先Build專案之後才能開啟。因此,如果我們想在客戶端上測試遊戲,就必須Build這個專案。
- File-Build Settings。
- 加入場景Main。
- 點選Build and run。
- 啟動時選擇一個較小的解析度,保證能夠同時看到編輯器。

遊戲啟動(後面稱這個例項為Instance)後,你應該能看到NetworkManagerHUD面板。
- 點選Host按鈕,這樣Instance會扮演Host。

這時你應該能看到一個Player。試著用WASD控制它。之後:
- 返回Unity。
- 進入Play模式。
- 點選LAN Client按鈕來扮演客戶端,並和Host建立連線。
- 試著用WASD控制它。

你會發現兩個Player都在移動。這時:
- 回到Instance。

你應該還會發現,在Editor中兩個Player的位置和Instance中不同。這是因為PlayerController指令碼現在還沒有聯網功能。當前,兩個Player上都附著同樣的指令碼。在兩個不同的例項中,這兩個指令碼都在處理同樣的輸入資訊。Host和Client彼此都能意識到對方的存在,NetworkManager也為它們分別建立了兩個不同的Player,但是Player物件沒有和Host進行交流,因此NetworkManager無法追蹤它的位置,簡單來說就是沒有同步。
接下來:
- 關掉Instance。
- 回到Unity。
- 退出Play模式。

(7)讓Player的移動線上化。

為了給Player的移動賦予線上特性,並保證每個玩家只能控制它們自己的Player,我們需要更新PlayerController指令碼。我們需要給指令碼做兩個大的改動:使用UnityEngine.Networking名稱空間,以及讓PlayerController繼承自NetworkBehaviour,而不是MonoBehaviour。
- 開啟PlayerController指令碼。
- 新增UnityEngine.Networking名稱空間。using UnityEngine.Networking;
- 將MonoBehaviour改成NetworkBehaviour。public class PlayerController : NetworkBehaviour

接下來加入一段程式碼,用於檢查是不是本地物件,這樣就能保證只有玩家只能控制對應的Player。

    if (!isLocalPlayer)
    {
        return;
    }

下面是完整的指令碼:

    using UnityEngine;
    using UnityEngine.Networking;

    public class PlayerController : NetworkBehaviour
    {
        void Update()
        {
            if (!isLocalPlayer)
            {
                return;
            }

            var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
            var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;

            transform.Rotate(0, x, 0);
            transform.Translate(0, 0, z);
        }
    }

UnityEngine.Networking名稱空間包含了編寫具有聯網功能的指令碼所需的內容。
NetworkBehaviour是一個基於Monobehaviour的特別的類。所有自定義的,使用聯網特性的指令碼都繼承自它。
注意到isLocalPlayer。LocalPlayer是NetworkBehaviour的一部分,所有繼承自NetworkBehaviour的指令碼都能夠理解它的含義。想要理解LocalPlayer是什麼,以及它是如何工作的的話,需要查閱關於HLAPI的文件。
在一個聯機專案中,無論是伺服器還是客戶端,執行的程式碼都來自於同一個指令碼,在上文中就是PlayerController指令碼。假設有一個伺服器端和兩個客戶端,那麼就會有6個需要處理的遊戲物件。這是因為玩家有兩名,伺服器端與兩個客戶端都會存在這兩個玩家控制的遊戲物件,2×3=6。

每個遊戲物件都是從同一個預製件克隆得到的,因此它們擁有同樣的指令碼檔案。如果指令碼是繼承自NetworkBehaviour的,那麼它就能夠了解到哪個物件屬於哪個玩家。LocalPlayer就是對應的客戶端擁有的遊戲物件。這個歸屬關係是由NetworkManager,在玩家連線進遊戲時建立的。當客戶端連線上伺服器後,客戶端上建立的遊戲物件就被標識為LocalPlayer。其他的遊戲物件,無論在客戶端上還是伺服器上,都不會被LocalPlayer。
通過檢查isLocalPlayer,我們就可以判斷指令碼是否要繼續執行。

    if (!isLocalPlayer)
    {
        return;
    }

這個判斷保證了只有LocalPlayer能夠執行移動物件的程式碼。
這裡還有一個問題,如果我們現在進行測試,Player物件依然沒有實現同步。每個Player只會在本地進行移動,而不會實時更新到網路上去。為了保證同步,我們需要為Player新增一個NetworkTransform元件。
- 儲存指令碼。
- 回到Unity。
- 在Project檢視中選中Player預製件。
- Add Component-Network > NetworkTransform。
- 儲存。


NetworkTransform會同步GameObject的移動與變化。
總結一下本節的內容:
- isLocalPlayer檢查保證了玩家只能控制自己的Player。
- NetworkTransform實現了Player之間的同步。

(8)測試聯機功能

測試之前,首先要把之前Build得到的舊版遊戲刪除,並重新Build一個新版本。之後的步驟和(6)中相同。如果你前面的步驟沒有出錯的話,此時兩個Player應當能夠獨立地移動,並且實現了同步。你可能會感覺到遠端的Player的移動不是很平滑,有一點卡頓的感覺。你需要記住一點:所有需要聯網的應用都回或多或少地收到網路條件的限制,也就是客戶端和伺服器間資料傳輸的速度。
有一些方法可以優化網路狀況與資料傳輸。比如,NetworkTransform有一個Network Send Rate設定,能夠確定NetworkTransform傳送同步資料的頻率。這對玩家的遊戲體驗會有非常大的影響。

更重要的是,一個需要聯網的應用有許多方法可以解決不同步的問題,比如插值、外推法以及其他不同形式的平滑與預測技術。不過我們的課程中不會涉及到這些。
最好能夠記住一些關鍵的概念。我們應當在同步資料的頻率和遊戲表現(Performance)上取得一個平衡。同步資料的頻率過高或是過低都不合適。如果想要讓使用者有較好的遊戲體驗,最好能夠為那些需要同步的遊戲物件進行一些預測,讓它們看起來似乎在平滑移動。任何聯機遊戲都不可能做到完美的同步,因為玩家所處的網路環境有好有壞。但是遊戲開發者應當付出一些努力,即使在比較差的網路環境下,至少要讓玩家感覺遊戲的同步狀態還不錯。接下來:
- 關掉Instance。
- 回到Unity。
- 退出Play模式。

(9)標識不同的玩家

現在,每個玩家控制的Player都是一模一樣的,這讓玩家無法判斷哪個Player屬於它。為了標識不同的玩家,我們需要給Player上色。
- 開啟PlayerController。
- 覆蓋OnStartLocalPlayer方法來為Player上色。

    public override void OnStartLocalPlayer()
    {
        GetComponent().material.color = Color.blue;
    }

此時,完整的程式碼如下:

    using UnityEngine;
    using UnityEngine.Networking;

    public class PlayerController : NetworkBehaviour
    {
        void Update()
        {
            if (!isLocalPlayer)
            {
                return;
            }

            var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
            var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;

            transform.Rotate(0, x, 0);
            transform.Translate(0, 0, z);
        }

        public override void OnStartLocalPlayer()
        {
            GetComponent<MeshRenderer>().material.color = Color.blue;
        }
    }

這個方法只會在LocalPlayer上呼叫,因此每個玩家看他們自己的Player時會發現是藍色的。OnStartLocalPlayer方法用來放置一些只會作用與LocalPlayer的程式碼,比如配置攝像機與輸入。
NetworkBehaviour中還有許多有用的方法,最好去查查它的文件。
接下來:
- 儲存指令碼。
- 回到Unity。
- 進行聯網測試(同8)。


你會發現自己控制的角色是藍色的。

(10)射擊(單人)

射擊是聯機遊戲的一個經典遊戲內容,玩家們能夠發射子彈,這些子彈在每個客戶端都能看到。本節會先介紹怎麼在單機環境下發射子彈,聯機部分在下一節。
- 建立一個新的Sphere Object。
- 重新命名為”Bullet”。
- 選中Bullet。
- 將其Scale改為(0.2, 0.2, 0.2)。
- Add Component-Physics > Rigidbody。
- 設定Rigidbody的Use Gravity為false。
- 把它拖到Project檢視中,建立一個Bullet預製件。
- 從場景中刪除Bullet。
- 儲存場景。

現在需要更新PlayerController指令碼,讓它具有發射子彈的功能。為此,指令碼需要持有Bullet的一個引用。
- 開啟PlayerController指令碼。
- 為Bullet新增一個public的域。

    public GameObject bulletPrefab;
  • 為子彈發射器新增一個Transform域。
    public Transform bulletSpawn;
  • 新增輸入處理邏輯:
    if (Input.GetKeyDown(KeyCode.Space))
    {
        Fire();
    }
  • 新增Fire()方法:
    void Fire()
    {
        // Create the Bullet from the Bullet Prefab
        var bullet = (GameObject)Instantiate (
            bulletPrefab,
            bulletSpawn.position,
            bulletSpawn.rotation);

        // Add velocity to the bullet
        bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;

        // Destroy the bullet after 2 seconds
        Destroy(bullet, 2.0f);
    }

最終的指令碼如下:

    using UnityEngine;
    using UnityEngine.Networking;

    public class PlayerController : NetworkBehaviour
    {
        public GameObject bulletPrefab;
        public Transform bulletSpawn;

        void Update()
        {
            if (!isLocalPlayer)
            {
                return;
            }

            var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
            var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;

            transform.Rotate(0, x, 0);
            transform.Translate(0, 0, z);

            if (Input.GetKeyDown(KeyCode.Space))
            {
                Fire();
            }
        }


        void Fire()
        {
            // Create the Bullet from the Bullet Prefab
            var bullet = (GameObject)Instantiate(
                bulletPrefab,
                bulletSpawn.position,
                bulletSpawn.rotation);

            // Add velocity to the bullet
            bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;

            // Destroy the bullet after 2 seconds
            Destroy(bullet, 2.0f);
        }

        public override void OnStartLocalPlayer ()
        {
            GetComponent<MeshRenderer>().material.color = Color.blue;
        }
    }
  • 儲存指令碼。
  • 回到Unity。

接下來建立一把槍:
- 把Player預製件拖進Scene中。
- 選中Player。
- 建立一個Cylinder作為它的子物體。
- 將其重新命名為”Gun”。
- 選中Gun。
- 移除它的Capsule Collider元件。
- 設定Position為:(0.5, 0.0, 0.5)。
- 設定Rotation為:(90.0, 0.0, 0.0)。
- 設定Scale為:(0.25, 0.5, 0.25)。
- 設定Material為Black。

完成圖:

接下來建立子彈發射器:
- 選中Player。
- 建立一個空GameObject作為其子物體。
- 將其重新命名為Bullet Spawn。
- 設定其Position為(0.5, 0.0, 1.0)。

建立完畢後,子彈發射器應當在槍口位置:

- 選中Player。
- 將改動儲存到Player預製件中。(直接拖到Project檢視的Player上)。
- 從場景中刪除Player。
- 儲存。

接下來要為PlayerController指令碼設定Bullet與Bullet Spawn的引用。
- 選中Player。
- 在Inspector檢視中開啟Player Controller標籤。
- 設定Bullet Prefab。
- 設定Bullet Spawn。
- 儲存。

接下來你可以進行單機測試和聯機測試。你會發現玩家只能看到自己發射的子彈。

(11)射擊(聯機)

這部分會為Bullet新增聯機特性。我們需要更新Bullet預製件和射擊程式碼。
前面的課程已經告訴我們,想要讓Bullet具有聯機特性,需要為其新增NetworkIdentity來標識它在網路上的獨特性,新增NetworkTransform來同步它的位置和旋轉。除此之外,還要向Network Manager將其註冊為一個Spawnable Prefab。
- 選中Bullet預製件。
- add component: Network > NetworkIdentity。
- add component: Network > NetworkTransform。
- 在NetworkTransform中,將Network Send Rate設定為0。

Bullet在射出之後,方向、速度和旋轉角都不會發生變化,因此不需要傳送更新資訊。每個客戶端都能夠自己計算出子彈在某個時刻所處的位置。通過將Network Send Rate設定為0,子彈的位置資訊將不會通過網路同步,因此可以降低網路負載。
接下來:
- 在Hierarchy檢視中選中NetworkManager。
- 開啟Spawn Info標籤。
- 在Registered Spawnable Prefabs列表中,通過+按鈕新增一行。
- 選中NetworkManager。
- 把Bullet加入到Registered Spawnable Prefabs列表中。


現在,我們需要更新PlayerController指令碼。從指令碼編輯器中開啟它。
之前,當我們討論如何讓Player的移動具有聯機特性時,我們提到過HLAPI的結構。一個基礎概念是伺服器和所有客戶端執行的都是同樣的指令碼。想要區分開伺服器和不同客戶端的行為,需要用isLocalPlayer來進行判定。
另外一種控制方法是使用[Command]特性(Attribute)。[Command]用來指明某個方法是由客戶端呼叫,但是是在伺服器上執行的。方法所需的引數都會和命令一起被傳遞到伺服器端。命令只能從本地專案例項中發出。當建立一個Command時,Command對應的方法必須以Cmd開頭。
- 為Fire方法新增[Command]特性,使其成為一個Command。
- 將其名稱改為CmdFire。

    [Command]
    void CmdFire()
  • 對應地,修改呼叫Fire方法的程式碼。
    CmdFire();

下一個需要知道的概念是Network Spawning(網路生成?)。在HLAPI中,”Spawn”不僅僅包含”Instantiate”,它意味著在伺服器和所有與其連線的客戶端上建立一個物件。這個物件會由spawning system(生成系統?)管理,當其在伺服器上發生改變時,狀態變更資訊會被髮送到客戶端。當伺服器上的該物件被摧毀時,客戶端上的該物件也會被摧毀。除此之外,網路系統還會持有所有spawned GameObject(生成的物件?)的引用,如果一個新玩家加入,這些物件也會在新玩家的客戶端上生成。
你需要在CmdFire方法中新增這麼一行程式碼:

    NetworkServer.Spawn(bullet);

這是最終的指令碼:

    using UnityEngine;
    using UnityEngine.Networking;

    public class PlayerController : NetworkBehaviour
    {
        public GameObject bulletPrefab;
        public Transform bulletSpawn;

        void Update()
        {
            if (!isLocalPlayer)
            {
                return;
            }

            var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
            var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;

            transform.Rotate(0, x, 0);
            transform.Translate(0, 0, z);

            if (Input.GetKeyDown(KeyCode.Space))
            {
                CmdFire();
            }
        }

        // This [Command] code is called on the Client …
        // … but it is run on the Server!
        [Command]
        void CmdFire()
        {
            // Create the Bullet from the Bullet Prefab
            var bullet = (GameObject)Instantiate(
                bulletPrefab,
                bulletSpawn.position,
                bulletSpawn.rotation);

            // Add velocity to the bullet
            bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;

            // Spawn the bullet on the Clients
            NetworkServer.Spawn(bullet);

            // Destroy the bullet after 2 seconds
            Destroy(bullet, 2.0f);
        }

        public override void OnStartLocalPlayer ()
        {
            GetComponent<MeshRenderer>().material.color = Color.blue;
        }
    }

儲存並回到Unity。接下來你可以進行測試了。不出意外的話,子彈已經能夠正確顯示在各個視窗中了。不過,現在子彈只會在其他玩家身上彈開,不會產生任何影響。

(12)玩家血量(單機)

在網路遊戲中,同步玩家狀態資訊是很重要的一個概念。下面我們會為Bullet新增傷害效果,被Bullet擊中會削減玩家的HP值。玩家的HP值就是一個需要在網路上進行同步的資料。

建立Bullet碰撞(Collisions)

首先為Bullet建立碰撞處理邏輯。這裡,我們僅僅讓子彈在撞到其他物體時摧毀自己。
- 為Bullet預製件新增一個指令碼,並改名為”Bullet”。
- 開啟指令碼編輯器。
- 補充邏輯(新版中方法簽名不太一樣):

    using UnityEngine;
    using System.Collections;

    public class Bullet : MonoBehaviour {

        void OnCollisionEnter()
        {
            Destroy(gameObject);
        }
    }
  • 儲存指令碼。
  • 回到Unity。

現在你可以進行一下測試。當子彈碰到其他玩家時,所有視窗中該子彈都會消失。

玩家血量

為了建立玩家的HP,我們需要一個新的指令碼來追蹤我們的Player的當前HP。
- 為Player預製件建立一個新的指令碼,並取名為”Health”。
- 開啟指令碼編輯器。
- 建立一個常量來確定HP最大值。

    public const int maxHealth = 100;
  • 建立一個變數來維護當前血量,初始時為maxHealth。
    public int currentHealth = maxHealth;

新增一個方法來削減HP:

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        if (currentHealth <= 0)
        {
            currentHealth = 0;
            Debug.Log("Dead!");
        }
    }

接下來,我們需要修改Bullet指令碼的OnCollisionEnter方法,新增以下程式碼:

    var hit = collision.gameObject;
    var health = hit.GetComponent<Health>();
    if (health != null)
    {
        health.TakeDamage(10);
    }

血槽

為了建立血槽,我們需要建立一些簡單的UI元件。下面的方法並不是最佳的選擇,我們僅僅是希望能用最簡單的方式來解決這個問題。
- 建立一個UI Image。

需要注意的是,這也會同時建立一個Canvas父物件和一個EventSystem物件。
- 將Canvas改名為”Healthbar Canvas”。
- 將Image改名為”Background”。
- 選中Background。
- 將Width設定為100。
- 將Height設定為10。
- 將它的Source Image設定為內建的InputFieldBackground。
- 設定其顏色為Red。
- 不要改動它的Anchor與Pivot。
- 拷貝BackGround物件。
- 將新的改名為”Foreground”,並將其設定為Background的子物件。
- 選中Foreground。
- 將其設定為綠色。
- 開啟Anchor Presets,並將其Pivot與Position設定為Middle Left。


這個HealthBar需要被加入到Player預製件中,並且和生命值與傷害系統繫結起來。
首先,需要將Canvas從預設的Overlay Canvas改成一個World Space Canvas,然後再將其加入到Player預製件中。
- 將Player預製件拖進Scene中。
- 選中HealthBar。
- 將Canvas的Render Mode改為World Space。
- 讓HealthBar成為Player的子物件。

此時的結構大致是這樣:

- 選中HealthBar。
- 對RectTransform執行reset(右上角的小齒輪)。
- 將RectTransform的Scale設定為(0.01, 0.01, 0.01)。
- 將RectTransform的Position設定為(0.0, 1.5, 0.0)。
- 選中Player。
- 將改動儲存進Player預製件中。
- 儲存。

為了將血槽繫結到生命值與傷害系統中,我們需要讓Health指令碼獲取它的引用,並根據當前HP設定Foreground的寬度。
- 開啟Health指令碼。
- 新增UnityEngine.UI名稱空間。

    using UnityEngine.UI;
  • 新增一個public的域來儲存Healthbar的RectTransform的引用。
    public RectTransform healthBar;

這裡我們需要引用的是HealthBar的Foreground的RectTransform的引用。有了這個引用,我們只需要根據當前血量設定它的Width屬性就可以了。

    healthBar.sizeDelta = new Vector2(
        currentHealth,
        healthBar.sizeDelta.y);

這裡我們使用了Vector2來設定其width與height。
完整的Health指令碼如下:

    using UnityEngine;
    using UnityEngine.UI;
    using System.Collections;

    public class Health : MonoBehaviour {

        public const int maxHealth = 100;
        public int currentHealth = maxHealth;
        public RectTransform healthBar;

        public void TakeDamage(int amount)
        {
            currentHealth -= amount;
            if (currentHealth <= 0)
            {
                currentHealth = 0;
                Debug.Log("Dead!");
            }

            healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
        }
    }
  • 儲存指令碼。
  • 回到Unity。
  • 在hirearchy檢視中選中Player。
  • 將它的Health元件中的Health Bar屬性設定為Foreground。
  • 將結果儲存到Player預製件中。
  • 在場景中刪除Player。
  • 儲存。

最後,我們需要讓HealthBar始終面朝主攝像機。
- 選中HealthBar。
- 為其新增一個新的指令碼,起名為Billboard。
- 開啟指令碼編輯器。
- 在Update方法中新增邏輯,使得HealthBar始終面朝主攝像機。

    transform.LookAt(Camera.main.transform);
  • 刪除多餘的程式碼。

完整的Billboard指令碼如下:

    using UnityEngine;
    using System.Collections;

    public class Billboard : MonoBehaviour {

        void Update () {
            transform.LookAt(Camera.main.transform);
        }
    }

現在你可以進行測試了。沒有問題的話,你應該能夠通過射擊削減目標的HP了。
現在,玩家HP的變化是在伺服器和客戶端上獨立進行的。當一個玩家射擊另一個玩家,客戶端和伺服器都在執行Bullet與Player的指令碼。這裡沒有進行任何同步。然而,子彈是由NetworkManager控制生成的。當檢測到碰撞時,所有客戶端上的子彈都會被摧毀。由於子彈在每個客戶端上都存在,因此子彈和玩家之間會有碰撞,玩家能夠受到來自子彈的傷害。但是,由於網路狀態的不穩定性,可能在某個客戶端上子彈已經發生了碰撞,而在另一個客戶端上子彈還沒生成。由於子彈是同步的,而HP不是同步的,玩家的HP在不同的客戶端上可能會產生差異。

(13)玩家血量(聯機)

想要解決上一節留下來的問題,一個方式是讓HP的變化僅僅發生在伺服器上,之後再讓伺服器對所有客戶端上的玩家血量進行同步。這個概念被稱為伺服器許可權(Server Authority)。
為了讓我們的生命值和傷害系統在伺服器許可權下工作,我們需要使用狀態同步(State Synchronization)和一個特殊的變數:SyncVars。需要網路同步的變數,或者說SyncVars,需要加上[SyncVar]特性。
- 開啟Health指令碼。
- 新增UnityEngine.Networking名稱空間。
- 讓指令碼繼承自NetworkBehaviour。
- 為currentHealth加上[SyncVar]特性。

    [SyncVar]
    public int currentHealth = maxHealth;
  • 為TakeDamage方法加上isServer判定,若false則直接返回。
    if (!isServer)
    {
        return;
    }

最終的Health指令碼如下:

    using UnityEngine;
    using UnityEngine.UI;
    using UnityEngine.Networking;
    using System.Collections;

    public class Health : NetworkBehaviour {

        public const int maxHealth = 100;

        [SyncVar]
        public int currentHealth = maxHealth;
        public RectTransform healthBar;

        public void TakeDamage(int amount)
        {
            if (!isServer)
            {
                return;
            }

            currentHealth -= amount;
            if (currentHealth <= 0)
            {
                currentHealth = 0;
                Debug.Log("Dead!");
            }

            healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y);
        }
    }

現在你可以進行測試了。這裡建議你讓Instance作為Host,編輯器作為客戶端,並控制Instance的Player射擊編輯器的Player。你會發現,Instance上的顯示是正確的,而Client上血條沒有變化。但是,如果你在Inspector檢視中檢視Player的當前血量屬性,你會發現它的確發生了變化:

這是因為我們僅僅同步了HP值,而沒有同步Foreground的寬度。改變Foreground寬度的程式碼在TakeDamage中,而這個方法由於isServer判定而只能在伺服器上執行,因此才會出現伺服器上能夠正常顯示,而客戶端上無法正確顯示的問題。
現在我們需要對Foreground的寬度進行同步。這裡我們需要使用另外一個狀態同步工具:SyncVar hook。SyncVar hook能夠將SyncVar連線到一個方法,當SyncVar發生變化,伺服器和所有客戶端上的這個方法都會被呼叫。需要注意的是,這個方法必須有一個引數,型別和SyncVar相同。當方法被呼叫時,SyncVar的當前值會被傳到方法的引數中。
下面演示它的使用方法:
- 開啟Health指令碼。
- 把改變Foreground寬度的程式碼移到一個單獨的方法中。

    void OnChangeHealth (int currentHealth)
    {
        healthBar.sizeDelta = new Vector2(health, currentHealth.sizeDelta.y);
    }
  • 為currentHealth的[SyncVar]特性新增一個屬性。
    [SyncVar(hook = "OnChangeHealth")]

最終的Health指令碼如下:

    using UnityEngine;
    using UnityEngine.UI;
    using UnityEngine.Networking;
    using System.Collections;

    public class Health : NetworkBehaviour {

        public const int maxHealth = 100;

        [SyncVar(hook = "OnChangeHealth")]
        public int currentHealth = maxHealth;

        public RectTransform healthBar;

        public void TakeDamage(int amount)
        {
            if (!isServer)
                return;

            currentHealth -= amount;
            if (currentHealth <= 0)
            {
                currentHealth = 0;
                Debug.Log("Dead!");
            }
        }

        void OnChangeHealth (int health)
        {
            healthBar.sizeDelta = new Vector2(health, healthBar.sizeDelta.y);
        }
    }

現在你可以進行測試了。此時客戶端和伺服器上的血條應當都能正確變化了。

(14)死亡與復位

現在,即便玩家的HP歸零也不會發生任何事情。為了讓這個示例更加像一個遊戲,我們讓玩家的Player在HP歸零時自動在出生點滿HP復活。這裡會用到狀態同步的另一個工具——[ClientRpc]特性。
ClientRpc指令可以由任何一個擁有NetworkIdentity的生成物件發出。這個方法由伺服器呼叫,但在客戶端上執行。ClientRpc恰好是Command的反義詞。Command是由客戶端呼叫,但由伺服器執行。
為了讓一個方法稱為Rpc方法,我們需要使用[ClientRpc]特性,並在方法的名字的前面加上Rpc。現在,這個方法會在客戶端上執行。儘管它是在伺服器上呼叫的。方法所需的引數會自動被髮送到客戶端。
為了新增一個復位功能,我們需要在Health指令碼中建立一個新的Respawn方法,並在TakeDamage中進行判定,如果HP歸零則呼叫這個方法。
- 開啟Health指令碼。
- 建立一個新的方法,命名為RpcRespawn,並加上[ClientRpc]特性。

    [ClientRpc]
    void RpcRespawn()
    {
        if (isLocalPlayer)
        {
            // move back to zero location
            transform.position = Vector3.zero;
        }
    }
  • 在TakeDamage中進行判定,如果HP歸零就讓其回滿,並呼叫RpcRespawn方法進行復位。

最終的Health指令碼如下:

    using UnityEngine;
    using UnityEngine.UI;
    using UnityEngine.Networking;
    using System.Collections;

    public class Health : NetworkBehaviour {

        public const int maxHealth = 100;

        [SyncVar(hook = "OnChangeHealth")]
        public int currentHealth = maxHealth;

        public RectTransform healthBar;

        public void TakeDamage(int amount)
        {
            if (!isServer)
                return;

            currentHealth -= amount;
            if (currentHealth <= 0)
            {
                currentHealth = maxHealth;

                // called on the Server, but invoked on the Clients
                RpcRespawn();
            }
        }

        void OnChangeHealth (int currentHealth )
        {
            healthBar.sizeDelta = new Vector2(currentHealth , healthBar.sizeDelta.y);
        }

        [ClientRpc]
        void RpcRespawn()
        {
            if (isLocalPlayer)
            {
                // move back to zero location
                transform.position = Vector3.zero;
            }
        }
    }

在我們的示例中,客戶端能夠操縱本地Player物件,這是因為Player在客戶端擁有本地許可權(local authority)。如果伺服器簡單地對Player進行復位,那麼客戶端會蓋過伺服器,因為Player的操作許可權在客戶端手上。為了避免這種情況,伺服器通過ClientRpc方法對客戶端發出指令,讓客戶端對Player進行復位。之後,由於NetworkTransform的作用,Player的位置資訊會在網路上同步。
現在你可以進行測試了。現在HP歸零的Player會在出生點滿血復活。

(15)處理非玩家控制的物件

到目前為止,我們的示例一直關注於玩家控制的物件。然而,許多遊戲中都存在一些非玩家控制的物件。這一節中,我們會專注於開發一些類似敵人的遊戲物件。
我們已經知道,玩家控制的Player物件是在客戶端連線上Host後生成的,它由玩家控制。與此相反,敵人物件全都是由伺服器控制的。
這一節中,我們會建立一個敵人生成器(Enemy Spawner),它能夠生成非玩家控制的敵人物件,這些物件可以被任意一個玩家攻擊與殺死。
- 建立一個空的GameObject。
- 重新命名為”Enemy Spawner”。
- 選中Enemy Spawner。
- add component: Network > NetworkIdentity。
- 在Inspector檢視的NetworkIdentity中,將Server Only設定為true。

將Server Only設定為true能夠防止Enemy Spawner被髮送到客戶端。
- 選中Enemy Spawner。
- 建立一個新的指令碼,並取名為EnemySpawner。
- 開啟指令碼編輯器。
- 用下面的程式碼替換掉原來的程式碼:

    using UnityEngine;
    using UnityEngine.Networking;

    public class EnemySpawner : NetworkBehaviour {

        public GameObject enemyPrefab;
        public int numberOfEnemies;

        public override void OnStartServer()
        {
            for (int i=0; i < numberOfEnemies; i++)
            {
                var spawnPosition = new Vector3(
                    Random.Range(-8.0f, 8.0f),
                    0.0f,
                    Random.Range(-8.0f, 8.0f));

                var spawnRotation = Quaternion.Euler( 
                    0.0f, 
                    Random.Range(0,180), 
                    0.0f);

                var enemy = (GameObject)Instantiate(enemyPrefab, spawnPosition, spawnRotation);
                NetworkServer.Spawn(enemy);
            }
        }
    }

關於上面這段程式碼,有幾個注意點:
- 需要新增UnityEngine.Networking名稱空間。
- 類需要繼承自NetworkBehaviour。
- 類覆蓋了一個OnStartServer方法。
- 當伺服器啟動,它會建立一組擁有隨機的初始位置和朝向的敵人。之後,它們會通過NetworkServer.Spawn(enemy)生成。

OnStartServer方法和前面用來為本地Player上色的OnStartLocalPlayer方法很像。OnStartServer是在伺服器開始監聽網路時呼叫。NetworkBehaviour類中還有許多能夠被覆蓋的方法,詳情請查詢文件。
接下來儲存指令碼並返回Unity。
現在Enemy Spawner已經建立完畢了,我們需要一個用於生成的敵人物件。為了儘可能地加快速度,我們會對Player預製件進行一些簡單的改動,讓他成為一個Enemy。事實上,Enemy和Player有許多共同之處,比如都需要NetworkIdentity和NetworkTransform,都需要生命值系統和血槽等。
- 把Player預製件拖進Hierarchy檢視。
- 將其改名為Enemy。
- 再將Enemy拖回Project檢視以建立一個Enemy預製件。

這麼做的目的是防止我們對Enemy的改動影響到Player。
- 刪除Enemy的Gun子物件。

Unity會警告我們這是一個會破壞預製件的行為。
- 點選continue。
- 選中Enemy。
- 刪除Bullet Spawn子物件。
- 在Inspector檢視中,移除PlayerController指令碼元件。

現在,這個Enemy已經可以準備上路了。不過,它和Player看起來太相似了,我們再做一些修改,讓它變得和Player不同。
- 對Enemy應用Black Material。
- 設定Visor的Material為Default-Material(可以建立一個新的Material並應用在Visor上)。
- 選中Enemy。
- 建立一個子Cube物件,並重命名為”Mohawk”。
- 將Position改為(0.0. 0.55, -0.15)。
- 將Scale改為(0.2, 1.0, 1.0)。
- 刪除Mohawk的BoxCollider元件。

最後,Enemy看起來會是這樣:

- 將改動儲存到Eneny預製件。
- 從Scene中刪除Enemy。
- 儲存。

最後,我們需要註冊Enemy預製件,並將其引用賦給Enemy Spawner。
- 在Hierarchy檢視中選中NetworkManager。
- 開啟Spawn Info標籤。
- 在Registered Spawnable Prefabs列表中新增一行。
- 新增Enemy預製件。
- 選中Enemy Spawner。
- 將Enemy預製件賦給Enemy Spawner的Enemy Prefab屬性。
- 設定敵人數量為4。


- 儲存。

現在你可以開始測試了。不出意外的話,你應當能夠看到幾個隨機出現的敵人,並且可以射擊它們。問題在於,即便把它們的HP打到0,它們不僅不會消失,而且HP會回到滿。這是因為復位功能在RpcRespawn方法中,而Enemy物件無法通過isLocalPlayer判定,因此不會復位。而回復HP的功能在TakeDamage方法中,這個方法是伺服器控制的。

(16)摧毀Enemy

我們需要進行一些改動,讓Enemy在HP歸零時被摧毀。最簡單的實現方法是對Health指令碼進行一些修改,讓它把Player和Enemy區分開。
- 開啟Health指令碼。
- 新增一個public的bool域destroyOnDeath。

    public bool destroyOnDeath;
  • 在TakeDamage方法中進行一下判定:
    if (destroyOnDeath)
    {
                Destroy(gameObject);
    }
    else
    {
              // existing Respawn code
    }

最終的Health指令碼如下:

    using UnityEngine;
    using UnityEngine.UI;
    using UnityEngine.Networking;
    using System.Collections;

    public class Health : NetworkBehaviour {

        public const int maxHealth = 100;

        public bool destroyOnDeath;

        [SyncVar(hook = "OnChangeHealth")]
        public int currentHealth = maxHealth;

        public RectTransform healthBar;

        public void TakeDamage(int amount)
        {
            if (!isServer)
                return;

            currentHealth -= amount;
            if (currentHealth <= 0)
            {
                if (destroyOnDeath)