# Unity多人連線教學 ## 前言 翻譯至 https://unity3d.com/learn/tutorials/topics/multiplayer-networking 2016.09.05 ## 事前準備 製作連線遊戲前,一定要有的兩個Script。 ### Network Manager ![Imgur](https://i.imgur.com/a2dyzrf.png) 連線機制管理器,遊戲中一定要有,功能很多後述。 * 在Unity中創一個空遊戲物件來掛載它。 ### Network Manager HUD ![Imgur](https://i.imgur.com/YEfO74B.png) 輔助Network Manager,顯示建立伺服器或是加入伺服器的UI,方便開發時偵錯用。 * 同樣和 Network Manager 掛載在同一個物件下。 遊戲中的UI長這樣: ![Imgur](https://i.imgur.com/X4TXuFv.png) ## 建立和註冊玩家 ### 建立玩家 * 準備要當成玩家的Prefab。 * 掛載 Network Identity 。 * 讓Network Manager 辨識並決定如何處置被掛載的物件。 * 有掛載此Component的物件才可被 Networking System 控制或產生。 * ![Imgur](https://i.imgur.com/NqmOnWs.png) * 勾選 Local Player Authority ,標記此物件是可被Client端操控的物件。 ### 註冊玩家 ![Imgur](https://i.imgur.com/Z4UuhYj.png) * 到 Network Manager * 把剛剛準備好的玩家Prefab,放到Spawn Info的 Player Prefab 欄位內。 ## 撰寫角色控制腳本 1. 新增Mono Behaviour,命名為 Player Controller ,並撰寫以下程式碼: ``` 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); } } ``` 2. 掛載到玩家Prefab上,讓玩家物件可以被移動。 * Get Axis方法中,Horizontal和 Vertical預設是WASD鍵控制,要自訂按鍵可至 Input Manager 修改。 ## 測試連線 1. 打包一個獨立執行檔,並執行它。 2. 如何當伺服器: 1. 確定是否有啟用HUD。 2. 點擊 LAN Host 。 3. HUD會轉變成如下圖,代表成功啟動: 4. ![Imgur](https://i.imgur.com/gAZRt1L.png) 5. 莊家會產生一個可以控制的角色,也可以看到其他連入的角色。 3. 如何連線到伺服器: 1. 確定是否有啟用HUD。 2. 點擊 LAN Client 。 3. 會看到畫面會出現自己的角色物件和伺服器端的角色。 4. 現在在伺服器已開,並一客戶端加入的情況下,移動角色。會發現兩隻角色會移動! * 這是因為角色的移動程式腳本沒有在程式碼內做出差異化,後述如何解決。 ## 角色移動差異化 在上一章節,移動角色不是很完全,會造成全部的角色都移動到。現在要講解怎麼解決它。 1. 首先打開 Player Controller 腳本。 2. 引用 UnityEngine.Networking 命名空間。 3. 把原本的基底類別 Mono Behaviour 改為 Network Behaviour 。 4. 在Update 方法(或是類似的 Late Update等不斷更新的方法)內新增判斷式: 1. Networking 命名空間包含有關多人連線的類別和相關型別。 2. Networking Behaviour 繼承至 Mono Behaviour 。被掛載的遊戲物件需要使用到多人連線的屬性或成員時,就需要使用。 IsLocalPlayer 為Networking Behaviour 內的成員。 ``` if (!isLocalPlayer) { return; } ``` 5. 現在再測試伺服器,發現只有伺服器的角色會移動。但在客戶端控制移動,伺服器端的畫面中客戶端角色卻不會移動! 6. 上述的問題需要在角色Prefab上,掛載 Network Transform 。 1. ![Imgur](https://i.imgur.com/WJGOl6e.png) 2. 此Component是用來同步角色的Transform。 3. 調整 Network Send Rate (Second) 回傳位置給伺服器同步的頻率。 1. 注意,適當的拿捏頻率是重要的地方。同步頻率太快會影響到遊戲效能,太慢會影響到遊戲體驗。 2. 可藉由內插和外插來彌補同步移動時不順的問題。 7. 這時再測試,會解決畫面移動同步的問題。 ## 本地角色詳解 ![Imgur](https://i.imgur.com/E1HxHZ5.png) * 在Network Behaviour 中,有很多用來使用的屬性,IsLocalPlayer 是其中之一。 * 在多人連線的專案中,伺服器和客戶端們都是在同時間,使用同樣的腳本和遊戲物件。當有一伺服器和兩個客戶端時,這表示會有六個角色遊戲物件同時在執行。 * 這是因為各個執行中的遊戲,各有兩個角色遊戲物件。伺服器本身不產生角色物件,但會有兩個客戶端的角色實體。客戶端互相會產生對方的角色實體。 * 所有的角色物件皆從同樣的Prefab和使用同樣的Player Controller腳本。掛載從 Network Behaviour 繼承的腳本的遊戲物件,會自動被識別並納入產生(Spawn)程序中。 * Local Player 代表此遊戲物件是本地端所有。在連接伺服器並產生角色時,會被 Network Manager 標示所有權。所以客戶端連線後,自己可控制的角色會被標記為 Local Player。 * 除了自己的角色外,即遠端物件不會被標記為 Local Player。 ## 本地角色特殊化 1. 打開 Player Controller 腳本。 2. 加入以下方法: * OnStartLocalPlayer 方法只會被本地角色呼叫,方便用來設定本地角色連線後的一些初始設定。 ``` public override void OnStartLocalPlayer() { GetComponent().material.color = Color.blue; } ``` 3. 測試後會發現,自己的角色會變成藍色的,其他角色依然是白色。 ## 設定攻擊手段 1. 準備子彈的Prefab。 2. 在Player Controller 腳本內設置可以發射子彈的機制。 ``` public GameObject bulletPrefab; public Transform bulletSpawn; //發射子彈觸發 if (Input.GetKeyDown(KeyCode.Space)) { Fire(); } void Fire() { // 產生實體 var bullet = (GameObject)Instantiate ( bulletPrefab, bulletSpawn.position, bulletSpawn.rotation); // 賦予力量給子彈 bullet.GetComponent<RigidBody>().velocity = bullet.transform.forward * 6; // 在兩秒後移除子彈 Destroy(bullet, 2.0f); } ``` 4. 調整角色的Prefab 1. 把子彈Prefab指定給Player Controller 腳本的 bullet Prefab 欄位。 2. 新增子彈產生點 遊戲物件給角色Prefab,並指定給Player Controller 腳本的 bullet Spawn 欄位。 5. 連線測試,會發現自己在發射子彈的時候,其他客戶端沒有同時呈現出來,在下一個章節會解釋如何修正。 ## 子彈發射同步 在此章節會解釋如何在多人連線下發射子彈,並對腳本和Prefab做修改。 1. 在子彈Prefab掛載Network Transform 。 1. ![Imgur](https://i.imgur.com/p6uTqwa.png) 2. 設定 Network Send Rate (Second) 為零。 * 因為子彈在發射後不會改變速度和方向,所以不需要送出同步更新。每個客戶端皆依賴子彈目標的位置。 * 可以減輕遊戲的效能負擔。 2. 在Network Manager中: 1. ![Imgur](https://i.imgur.com/tFHI2xx.png) 2. 在 Spawn Info 中的 Registered Spawnable Preafbs 把子彈Prefab加進去。 3. 修改 Player Controller 腳本。 4. 把Fire方法改名為 CmdFire ,並加上屬性 Command 。 1. Command屬性標示此方法,從客戶端送出並由Server端發送給其他客戶端。 2. 只能從本地玩家中送出。 3. 使用屬性時,方法名稱必須都帶有Cmd前綴字。 5. 在CmdFire中新增述句: ``` NetworkServer.Spawn(bullet); ``` 1. 此述句功能類似Game Object的 Instantiate ,要求伺服器端和所有有連線的客戶端產生子彈物件。 2. 被產生的物件是被產生系統管理,不管修改或刪除都會全部同步。 6. 測試結果:角色發射子彈時每個客戶端都會有同步,但是擊中後沒有任何效果。 ## 設定角色血量 在以下的步驟,會製作子彈打到角色後,會扣除角色的血量。而每個角色的血量會同步到整個連線。 ### 子彈碰撞 1. 建立 Mono Behaviour 腳本,命名為 Bullet 2. 修改程式碼為如下: ``` using UnityEngine; using System.Collections; public class Bullet : MonoBehaviour { void OnCollisionEnter() { Destroy(gameObject); } } ``` 4. Bullet 腳本掛載到 Bullet Prefab。 5. 測試,現在子彈碰到角色會自動消滅。且因為子彈 Prefab 是被 Network Manager 控管,所以所有連線的客戶端都會被同步到。 ### 角色血量 1. 建立 Mono Behaviour 腳本,命名為 Health 2. 建立一個常數,代表血量的最大值 ``` public const int maxHealth = 100; ``` 3. 建立一個變數,代表目前角色的血量,初始預設為最大值 ``` public int currentHealth = maxHealth; ``` 4. 建立一個方法,實作扣血的機制 ``` public void TakeDamage(int amount) { currentHealth -= amount; if (currentHealth <= 0) { currentHealth = 0; Debug.Log("Dead!"); } } ``` 5. 儲存腳本。 6. 修改 Bullet 腳本。 7. 修改 OnCollisionEnter 方法,讓它可以帶入參數。 ``` void OnCollisionEnter(Collision collision) ``` 8. 新增述句,讓子彈碰到角色時可以被扣血。 ``` var hit = collision.gameObject; var health = hit.GetComponent<Health>(); if (health != null) { health.TakeDamage(10); } ``` 9. 儲存腳本。但是現在只有數據,接下來要製作顯示UI。 ### 血量UI 1. 在場景中新增 UIImage。 2. 新增後,Unity 會自動幫你產生 Canvas 和 EventSystem 物件。 3. Canvas 更名為 Healthbar Canvas 。 4. Image 更名為 Background ,並設定: 1. 設定 RectTransform Component 的寬為 100。 2. 同上,高為 10。 3. 設定 Image Component 的圖像來源為內建的 InputFieldBackground。 4. 設定 Image Component 的顏色為紅色。 5. 複製 Background 物件,命名為 Foreground ,並設定為 Background 的子物件。 6. 設定 Foreground 物件: 1. 設定 Image Component 的顏色為綠色。 2. 打開 RectTransform Component 的錨點設定( Anchor Presets Window )視窗,設定中心點和位置分別為中間和靠左。 3. ![Imgur](https://i.imgur.com/fWHpJ42.png) 7. 把 Healthbar Canvas 的 Render Mode 轉成 World Space 。 1. 此舉是將UI化為3D物件,讓UI可以跟著角色跑。 8. 將 Healthbar Canvas (含底下的子物件) 作為角色Prefab的子物件。 * ![Imgur](https://i.imgur.com/FBMmFd0.png) 10. 設定 Healthbar Canvas 的 RectTransform Component : 1. 比例設定為 (0.01, 0.01, 0.01)。 2. 位置設定為 (0.0, 1.5, 0.0)。 ### 血量UI綁定腳本 1. 修改 Heath 腳本。 2. 使用UI命名空間,且新增一變數用來控制UI。 ``` using UnityEngine.UI; public RectTransform healthBar; ``` 3. 新增述句,此機制是用來控制UI的血量條的長度。 ``` healthBar.sizeDelta = new Vector2(currentHealth, healthBar.sizeDelta.y); ``` 4. 設定角色Prefab,把剛剛做好的UI指定給 healthBar 。 5. 最後,我們需要讓血量條永遠面對攝影機。 6. 選擇 Healthbar Canvas 物件。 7. 新增腳本,命名為 Billboard 。 8. 在腳本內的 Update 方法新增以下此述句: ``` transform.LookAt(Camera.main.transform); ``` 9. 移除腳本內沒用到的程式碼。 10. 測試,在客戶端發現子彈打到角色會扣血,但是沒有在所有已連線的客戶端中同步。 ## 角色血量同步 有種情況是,變動須從伺服器廣播通知所有客戶端更新,此方式稱之為 Server Authority (伺服器權力)。 要讓血量藉由伺服器權力來進行同步,需要設定變數為 SyncVars ,這稱之為 State Synchronization (狀態同步)。 1. 修改 Health 腳本,引用 Networking 命名空間和改為 NetworkBehaviour。 ``` using UnityEngine.Networking; public class Health : NetworkBehaviour ``` 2. 附加屬性 SyncVars 給目前血量變數。 ``` [SyncVar] public int currentHealth = maxHealth; ``` 3. 設定判斷在 TakeDamage 方法,讓血量只能在伺服器端控制。 ``` if (!isServer) { return; } ``` 4. 測試後發現,因為UI還沒設置同步,無法很明確的測試血量是否都同步。必須在編輯器底下才能看到數值的變化。 5. 現在我們要把UI也一併做同步。現在使用 SyncVar hook 來掛勾同步變數和方法,當變數被同步時,也會同時呼叫被掛勾的方法。 6. 把上一章修改UI的述句,移到以下的方法內: * 值得注意的是,方法的參數型別必須和掛鉤的同步變數一致,才能成功地把值傳入到方法內。 ``` void OnChangeHealth (int currentHealth) { healthBar.sizeDelta = new Vector2(health, currentHealth.sizeDelta.y); } ``` 7. 增加屬性在方法宣告前方,告知要掛勾哪個變數 ``` [SyncVar(hook = "OnChangeHealth")] ``` 9. 現在測試,互相射擊對方會發現血量UI都同步了! ## 死亡和重生 現在,當角色血量到零時只會顯示Log,而不會有其他效果。現在我們希望能在血量到零時,會回到出生點。 所以現在介紹另外一項同步的工具,屬性 [ClientRpc] 。ClientRpc 可以在伺服器端,被任何掛有 NetworkIdentity 的產生物件發送。 Command 是從客戶端發送,由伺服器執行,而 ClientRpc 剛好相反,是由伺服器發送,由客戶端執行。 1. 在 Health 腳本新增方法 Respawn ,讓伺服器端,TakeDamage 方法內判斷玩家血量等於零時,去呼叫此方法。 2. 就像使用 Command 一樣,使用 ClientRpc 的方法名稱也需要加上Rpc前綴詞。 ``` [ClientRpc] void RpcRespawn() { if (isLocalPlayer) { // move back to zero location transform.position = Vector3.zero; } } ``` 3. 現在回到 TakeDamage 方法做修改,設定血量回到預設值。 ``` currentHealth = maxHealth; ``` 4. 用呼叫 RpcReSpawn 方法來取代原先的 Log ``` // called on the Server, but invoked on the Clients RpcRespawn(); ``` 5. 之前的示範都是客戶端控制角色,因為客戶端有 LocalAuthority 。所以伺服器端只有在角色血量歸零時,直接移回出生點的話,客戶端可以輕易的覆蓋伺服器端的結果。 6. 要避免這種情況,伺服器端藉由 ClientRpc 來控制所有連線的客戶端角色血量歸零時回到原點。因為角色掛載 Network Transform ,而所有的客戶端都會同步位置。 7. 測試,現在當角色血量歸零時,所有的客戶端該角色都會同步自動回到出生點。 ## 處理非角色物件 在先前的章節,我們都在處理玩家。但是在遊戲中可能會有其他由伺服器控制的物件。在這個範例內專注於處理敵人這種會變化的伺服器物件,而不注意場景物件。 1. 製作敵人的出生點,新增空遊戲物件,命名為 Enemy Spawner 。 2. 掛載 NetworkIdentity Component ,並將 Server Only 打勾,表示這是伺服器才可觸發的物件。 3. 新增 MonoBehaviour 腳本,掛載在 Enemy Spawner 上。 4. 修改腳本程式碼為: ``` 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); } } } ``` 5. 上方的程式碼裡面做了四件事: 1. 使用 Networking 命名空間。 2. 改用 NetworkBehaviour。 3. 改寫 OnStartServer 方法。 4. 當伺服器端開始執行時,會產生數個敵人在敵人出生點上。 5. OnStartServer 相當類似 OnStartLocalPlayer ,但在這情況下,前者是在伺服器端開始監控連線時被觸發。 6. 準備敵人的角色物件,我們從原本的玩家角色物件為基底重製。 7. 移除一些沒在敵人身上用到的物件,像是子彈發射腳本和角色控制腳本等等。 8. 到 Netowrk Manager ,把敵人的角色Prefab加到 Registered Spawnable Prefabs 內。 9. 回到 Enemy Spawner ,把敵人的角色Prefab指定給 enemyPrefab 欄位。 10. 設定產生的敵人數量為4。 * ![Imgur](https://i.imgur.com/rdwx9TK.png) 12. 儲存場景和專案。 13. 因為敵人扣血的機制和UI是要和玩家一樣的,所以就不修改腳本。 14. 測試,射擊敵人會扣血,但是當血量歸零時會跳回滿血狀態。因為敵人是伺服器物件,不會進到 RpcRespawn 方法內。 ## 讓敵人倒 現在我們需要讓敵人被射擊,血量到零時消滅。要達成此效果,首先要在角色和敵人的述句做出歧異。 1. 修改 Heath 腳本,新增此變數。 ``` public bool destroyOnDeath; ``` 2. 在 TakeDamage 方法內,在血量歸零的述句內新增判斷: ``` if (destroyOnDeath) { Destroy(gameObject); } else { // existing Respawn code } ``` 3. 存檔,到敵人的角色Prefab內會發現多出了 destroyOnDeath 欄位。 4. 把 destroyOnDeath 欄位打勾,並儲存Prefab。 * ![Imgur](https://i.imgur.com/Bd8RxFz.png) 6. 測試,現在攻擊敵人血量歸零時會消滅,但是消滅後敵人出生點沒有再次產生敵人出來,下一章會解決此問題。 ## 生成和再生成 #### 初始化生成點 現在玩家角色的生成點是從原點( Vector3.zero )開始。當有多個玩家連線,會重疊在出生點上,正常的情況是角色會在不同的地方生成。 不過連線系統提供了 NetworkStartPosition Component 來處理產生位置。 1. 首先產生空遊戲物件,命名為 Spawn Position 1 。 2. 掛載 NetworkStartPosition Component。 3. 設定位置。 4. 複製 Spawn Position 1 物件,改名為 Spawn Position 2 。 5. 改變 Spawn Position 2 的位置。 6. 到 Network Manager ,設定 Player Spawn 為 Round Robin 。 1. Network Manager 會自動場景上掛有 NetworkStartPosition 的物件,並取用其位置產生玩家。 2. Player Spawn 有兩種方法:Random 和 Round Robin 。 1. Random 作用為會在可用 NetworkStartPosition 的物件隨機挑一個出來使用。 2. Round Robin 作用為優先使用沒用過的 NetworkStartPosition 的物件,直到全部都輪流到了,才會開始再使用。 #### 設定生成點 在此教學最後一步,利用 NetworkStartPosition component 的陣列來製作一個簡單的玩家隨機生成系統。雖然跟網路連線比較無關,但會讓此範例更完整。 1. 開啟 Health 腳本,新增放置出生點的陣列。 ``` private NetworkStartPosition[] spawnPoints; ``` 2. 新增 Start 方法。 ``` void Start () { } ``` 3. 判斷此物件是不是本地玩家 ``` if (isLocalPlayer) { } ``` 4. 找出所有掛載 NetworkStatrtPosition 的物件並加到陣列內 ``` spawnPoints = FindObjectsOfType<NetworkStartPosition>(); ``` 5. 在RpcRespawn 方法內移除此述句 ``` // Set the player’s position to origin transform.position = Vector3.zero; ``` 6. 替換為 ``` // 宣告並設定區域變數為原點 Vector3 spawnPoint = Vector3.zero; // 假如陣列有東西,就隨便拿一個出來用 if (spawnPoints != null && spawnPoints.Length > 0) { spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position; } // 把取出來的出生點位置指定給角色物件 transform.position = spawnPoint; ``` 7.儲存並測試,現在遊戲一連線上,角色會在隨機的位置上生成。