Photon 社群小聚 (2019.08) 活動記錄

(Roll-a-ball 多人連線版本)

Ryan Tseng
Sep 2, 2019 · 25 min read

Photon Hands On 社群小聚, 是由 Photon Taiwan 主辦, 極客窩大力支援協辦的系列活動. 目的是藉由實機展示與逐步修改遊戲專案, 來帶領初階開發者快速入門 Photon 產品功能.


2019.08 於極客窩舉辦的 Photon Hands On 活動, 是由 Photon Taiwan Evangelist- Ryan Tseng 來實作展示, 將 Unity Roll-a-ball tutorial 單人版本滾球遊戲, 結合 PUN2 套件, 改成多人連線版本.

當日的簡報與專案檔, 可從 https://bit.ly/2PKnSuS下載取得.

本篇文章並不深入解釋步驟或程式碼的細節, 建議初階的讀者, 先看過下列的資料, 再來跟簡報演示的步驟, 會比較清楚.

1. Unity Roll-a-ball tutorial 單人版本滾球遊戲教程
2. PUN2 | 連線射擊遊戲 系列文章

原本的 Roll-a-ball 單人版本滾球遊戲

結合 PUN2, 變成多人連線版本的滾球遊戲.


讓我們開始吧.

(1) 首先, 開啟 Roll-a-ball 專案檔, 匯入 PUN2 Free 套件, 並輸入 AppID.

(對照 PUN 2 | 連線射擊遊戲 (1/4) 文章中有詳細說明.)

(2) Lobby 階段, 與 Photon Cloud 建立連線.

(對照 PUN 2 | 連線射擊遊戲 (1/4) 文章中有詳細說明.)

編輯 C# Script- Launcher, “與 Photon Cloud 建立連線” 的程式碼為:

using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using UnityEngine;

namespace Com.ABCDE.RollABall
{
public class Launcher : MonoBehaviour
{
// 遊戲版本的編碼, 可讓 Photon Server 做同款遊戲不同版本的區隔.
string gameVersion = "1";

void Awake()
{
// 確保所有連線的玩家均載入相同的遊戲場景
PhotonNetwork.AutomaticallySyncScene = true;
}

void Start()
{
Connect();
}

// 與 Photon Cloud 連線
public void Connect()
{
// 檢查是否與 Photon Cloud 連線
if (PhotonNetwork.IsConnected)
{
// 已連線, 嚐試隨機加入一個遊戲室
PhotonNetwork.JoinRandomRoom();
}
else
{
// 未連線, 嚐試與 Photon Cloud 連線
PhotonNetwork.GameVersion = gameVersion;
PhotonNetwork.ConnectUsingSettings();
}
}
}
}

編輯 C# Script- Launcher, “實作 PUN Callbacks“ 的程式碼為:

把 class Launcher 改為繼承 MonoBehaviourPunCallbacks

using Photon.Realtime;

(...)

public class Launcher: MonoBehaviourPunCallbacks

與 “實作 PUN Callback Functions” :

public override void OnConnectedToMaster()
{
Debug.Log("PUN 呼叫 OnConnectedToMaster(), 已連上 Photon Cloud.");

// 確認已連上 Photon Cloud
// 隨機加入一個遊戲室
PhotonNetwork.JoinRandomRoom();
}
public override void OnDisconnected(DisconnectCause cause)
{
Debug.LogWarningFormat("PUN 呼叫 OnDisconnected() {0}.", cause);
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
Debug.Log("PUN 呼叫 OnJoinRandomFailed(), 隨機加入遊戲室失敗.");

// 隨機加入遊戲室失敗. 可能原因是 1. 沒有遊戲室 或 2. 有的都滿了.
// 好吧, 我們自己開一個遊戲室.
PhotonNetwork.CreateRoom(null, new RoomOptions());
}
public override void OnJoinedRoom()
{
Debug.Log("PUN 呼叫 OnJoinedRoom(), 已成功進入遊戲室中.");
}

編輯 C# Script- Launcher, 修改遊戲室玩家人數的上限與 CreateRoom() 的玩家人數控制的程式碼:

在 class Launcher 的最前面, 加入

[Tooltip("遊戲室玩家人數上限. 當遊戲室玩家人數已滿額, 新玩家只能新開遊戲室來進行遊戲.")]
[SerializeField]
private byte maxPlayersPerRoom = 4;

並把在 OnJoinRandomFailed() 裡面的 PhotonNetwork.CreateRoom() 帶入玩家人數上限的控制變數.

PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 
maxPlayersPerRoom });

(3) Lobby 階段, 實作連線按鈕.

(對照 PUN 2 | 連線射擊遊戲 (1/4) 文章中有詳細說明.)

編輯 C# Script- PlayerNameInputField, 輸入 “設定遊戲玩家名稱” 的程式碼:

using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using UnityEngine;
using UnityEngine.UI;

namespace Com.ABCDE.RollABall
{
[RequireComponent(typeof(InputField))]
public class PlayerNameInputField : MonoBehaviour
{
const string playerNamePrefKey = "PlayerName";

void Start()
{
string defaultName = string.Empty;
InputField _inputField =
this.GetComponent<InputField>();
if (_inputField != null)
{
if (PlayerPrefs.HasKey(playerNamePrefKey))
{
defaultName = PlayerPrefs.
GetString(playerNamePrefKey);
_inputField.text = defaultName;
}
}
// 設定遊戲玩家的名稱
PhotonNetwork.NickName = defaultName;
}

public void SetPlayerName(string value)
{
if (string.IsNullOrEmpty(value))
{
Debug.LogError("Player Name is null or empty");
return;
}
// 設定遊戲玩家的名稱
PhotonNetwork.NickName = value;
PlayerPrefs.SetString(playerNamePrefKey, value);
}
}
}

把 component- PlayerNameInputField 拖拉到 component- InputField 的 On Value Changed, 並選取 PlayerNameInputField.SetPlayerName().

編輯 C# Script- Launcher, 加入 “宣告變數” 與 “設定顯示或隱藏” 的程式碼:

在 class Launcher 的最前面, 加入下列程式碼:

[Tooltip("顯示/隱藏 遊戲玩家名稱與 Play 按鈕")]
[SerializeField]
private GameObject controlPanel;
[Tooltip("顯示/隱藏 連線中 字串")]
[SerializeField]
private GameObject progressLabel;

並在 Start() 中加入 下列程式碼:

progressLabel.SetActive(false);
controlPanel.SetActive(true);

與在 Connect() 中加入 下列程式碼:

progressLabel.SetActive(true);
controlPanel.SetActive(false);

以及, 在 OnDisconnected() 中加入 下列程式碼:

progressLabel.SetActive(false);
controlPanel.SetActive(true);

(4) Room 階段, 配對與進入遊戲室.

(對照 PUN 2 | 連線射擊遊戲 (2/4) 文章中有詳細說明.)

編輯 C# Script- GameManager, 加入 “OnLeftRoom” 的程式碼:

using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace Com.ABCDE.RollABall
{
public class GameManager : MonoBehaviourPunCallbacks
{
// 玩家離開遊戲室時, 把他帶回到遊戲場入口
public override void OnLeftRoom()
{
SceneManager.LoadScene(0);
}
public void LeaveRoom()
{
PhotonNetwork.LeaveRoom();
}
}
}

這邊要特别提醒一下:
部落格文章的示範, 和社群小聚活動的實機演示, 有個細微的差異 ~
部落格文章的示範, 其場景大小會隨遊戲室的人數改變. 因此, 會需要在 OnPlayerEnteredRoom() 與 OnPlayerLeftRoom() 中去重載場景.
而在社群小聚活動的實機演示, 場景是固定的, 並不會隨遊戲室的人數而改變, 也就沒有做重載場景的動作. 也就不需要實作 OnPlayerEnteredRoom() 等介面.

編輯 C# Script- GameManager, 加入 “LoadArena()” 的程式碼:

void LoadArena()
{
if (!PhotonNetwork.IsMasterClient)
{
Debug.LogError("我不是 Master Client, 不做載入場景的動作");
}
Debug.Log("載入 MiniGame 遊戲場景");
PhotonNetwork.LoadLevel("MiniGame");
}

編輯 C# Script- Launcher, 在 “OnJoinedRoom() 增加載入場景” 的程式碼:

if (PhotonNetwork.CurrentRoom.PlayerCount == 1)
{
Debug.Log("我是第一個進入遊戲室的玩家");
Debug.Log("我得主動做載入場景 MiniGame 的動作");
PhotonNetwork.LoadLevel("MiniGame");
}

編輯 C# Script- Launcher, 在 “OnConnectedToMaster() 判斷 isConnecting 是否為 true” 的程式碼:

在 C#Script- Launcher, class Launcher 的最前面, 加上

bool isConnecting;

並在 Connect() 的最前面, 加上

isConnecting = true;

以及, 在 OnConnectedToMaster() 中, 呼叫 PhotonNetwork.JoinRandomRoom() 前, 先判斷 isConnecting 是否為 true.

public override void OnConnectedToMaster()
{
Debug.Log("PUN 呼叫 OnConnectedToMaster(), 已連上 Photon Cloud.");

// 確認已連上 Photon Cloud
// 隨機加入一個遊戲室
if (isConnecting)
{
PhotonNetwork.JoinRandomRoom();
}
}

(5) 玩家角色, 建立 Prefab- Player.

(對照 PUN 2 | 連線射擊遊戲 (3/4)PUN 2 | 連線射擊遊戲 (4/4) 文章中有詳細說明.)

將 component- PhotonTransformView 設為 component- PhotonView 的 Observed Components 後, Photon View 會在多人連線的環境自動做位置的同步.

勾選 Constraints 的 Freeze Rotation 的 x 和 y, 可避免球滾動時, 相機也跟一起滾動.

編輯 C# PlayerController, 將 class PlayerController 改繼承自 MonoBehaviourPunCallbacks, 並在 Start() 做相機跟隨.

using Photon.Pun;
using Photon.Pun.Demo.PunBasics;
public class PlayerController : MonoBehaviourPunCallbacks
{
(....)
void Start()
{
(....)
CameraWork _cameraWork =
this.gameObject.GetComponent<CameraWork>();
if (_cameraWork != null)
{
if (photonView.IsMine)
{
_cameraWork.OnStartFollowing();
}
}
else
{
Debug.LogError("playerPrefab- CameraWork component 遺失",
this);
}
}
}

(6) 資訊與狀態同步, 判斷 IsMine 與同步得分資訊.

(對照 PUN 2 | 連線射擊遊戲 (3/4)PUN 2 | 連線射擊遊戲 (4/4) 文章中有詳細說明.)

將 C# Script- PlayerController 的 countText 與 winText 相關的程式碼註解掉, 並將 count 改為 public, 以及加上 namespace 的宣告.

編輯 C# PlayerController, 修改 FixedUpdate() 與 OnTriggerEnter().

void FixedUpdate()
{
if (!photonView.IsMine)
{
return;
}
(....)
}
void OnTriggerEnter(Collider other)
{
if (!photonView.IsMine)
{
return;
}
(....)
}

在 C# Script- PlayerController 實作 OnPhotonSerializeView() 後, 將 component- PlayerController 設為 component- PhotonView 的 Observed Components 後, Photon View 會在多人連線的環境自動做得分的同步.

編輯 C# Script- PlayerController, 增加繼承自 IPunObservable.

public class PlayerController : MonoBehaviourPunCallbacks, 
IPunObservable
{
(....)
}

與輸入 OnPhotonSerializeView() 的程式碼.

public void OnPhotonSerializeView(PhotonStream stream, 
PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(count);
}
else
{
this.count = (int)stream.ReceiveNext();
}
}

編輯 C# Script- PlayerController, 在 FixedUpdate() 編寫贏得遊戲時的動作.

void FixedUpdate()
{
...

if (count > 3)
{
GameManager.Instance.LeaveRoom();
}
}

與編輯 C# Script- GameManager, 在 Start() 設定 instance = this.

public class GameManager : MonoBehaviourPunCallbacks
{
public static GameManager Instance;
void Start()
{
Instance = this;
}
(....)
}

(7) 資訊與狀態同步, 實例化玩家角色.

(對照 PUN 2 | 連線射擊遊戲 (3/4)PUN 2 | 連線射擊遊戲 (4/4) 文章中有詳細說明.)

編輯 C# Script- GameManager, 宣告變數 playerPrefab, 並在 Start() 實例化玩家的角色.

public class GameManager : MonoBehaviourPunCallbacks
{
(....)
[Tooltip("Prefab- 玩家的角色")]
public GameObject playerPrefab;
void Start()
{
(....)

if (playerPrefab == null)
{
Debug.LogError("playerPrefab 遺失", this);
}
else
{
Debug.LogFormat("我們從 {0} 實例化玩家角色",
SceneManagerHelper.ActiveSceneName);
Debug.LogFormat("playerPrefab 的 name 為 {0} ",
this.playerPrefab.name);

PhotonNetwork.Instantiate(this.playerPrefab.name,
new Vector3(0f, 5f, 0f), Quaternion.identity, 0);
}
}
}

編輯 C# Script- PlayerController, 宣告 LocalPlayerInstance 變數, 並增加 Awake() 標註為 DontDestroyOnLoad().

public class PlayerController : MonoBehaviourPunCallbacks, 
IPunObservable
{
(....)
[Tooltip("玩家角色的 instance")]
public static GameObject LocalPlayerInstance;
void Awake()
{
// 記錄玩家角色的 instance, 避免在重載場景時, 又再生成一次
if (photonView.IsMine)
{
PlayerManager.LocalPlayerInstance = this.gameObject;
}
// 標註玩家角色的 instance 不會在重載場景時被砍殺掉
DontDestroyOnLoad(this.gameObject);
}
}

編輯 C# Script- GameManager, 在 Start() 判斷 LocalPlayerInstance 的狀態.

void Start()
{
Instance = this;

if (playerPrefab == null)
{
Debug.LogError("playerPrefab 遺失", this);
}
else
{
if (PlayerController.LocalPlayerInstance == null)
{
Debug.LogFormat("我們從 {0} 實例化玩家角色",
SceneManagerHelper.ActiveSceneName);
Debug.LogFormat("playerPrefab 的 name 為 {0} ",
this.playerPrefab.name);
PhotonNetwork.Instantiate(this.playerPrefab.name,
new Vector3(0f, 5f, 0f), Quaternion.identity, 0);
}
else
{
Debug.LogFormat("忽略場景載入 for {0}",
SceneManagerHelper.ActiveSceneName);
}
}
}

(8) 資訊與狀態同步, 同步顯示角色資訊.

(對照 PUN 2 | 連線射擊遊戲 (3/4)PUN 2 | 連線射擊遊戲 (4/4) 文章中有詳細說明.)

新增 C# Script- PlayerInfo, 並輸入 “宣告變數” 的程式碼.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace Com.ABCDE.RollABall
{
public class PlayerInfo : MonoBehaviour
{
[Tooltip("玩家名稱")]
[SerializeField]
private Text playerName;
[Tooltip("玩家得分")]
[SerializeField]
private Text countText;
}
}

編輯 C# Script- PlayerInfo, 新增變數 target, SetTarget() 與 Update() 的程式碼.

public class PlayerInfo : MonoBehaviour
{
(....)
private PlayerController target; public void SetTarget(PlayerController _target)
{
if (_target == null)
{
Debug.LogError("傳入的 PlayerController instance 為空值",
this);
return;
}
target = _target;
if (playerName != null)
{
playerName.text = target.photonView.Owner.NickName;
}
}
void Update()
{
if (countText != null)
{
countText.text = target.count.ToString();
}
}
}

編輯 C# Script- PlayerController, 新增變數 PlayerInfoPrefab 與 Start() 的程式碼:

public class PlayerController : MonoBehaviourPunCallbacks, 
IPunObservable
{
(....)
[Tooltip("指標- GameObject PlayerInfo")]
[SerializeField]
public GameObject PlayerInfoPrefab;
(....) void Start()
{
(....)
if (PlayerInfoPrefab != null)
{
GameObject _uiGo = Instantiate(PlayerInfoPrefab);
_uiGo.SendMessage("SetTarget", this,
SendMessageOptions.RequireReceiver);
}
else
{
Debug.LogWarning("指標- GameObject PlayerInfo 為空值",
this);
}
}
}

編輯 C# Script- PlayerInfo, 修改 Update() 與 Awake() 的程式碼:

void Update()
{
(....)
// 當有不明原因, Photon 沒有將 Player 相關的 instance 清乾淨時
if (target == null)
{
Destroy(this.gameObject);
return;
}
}
void Awake()
{
this.transform.SetParent(GameObject.Find("Canvas").
GetComponent<Transform>(), false);
}

編輯 C# Script- PlayerInfo, 新增變數 “與角色頭頂 的距離” 的程式碼:

public class PlayerInfo : MonoBehaviour
{
(....)
[Tooltip("名字字串在角色頭頂的距離")]
[SerializeField]
private Vector3 screenOffset = new Vector3(0f, 5f, 0f);
float ballHeight = 5f;
Vector3 targetPosition;
(....)
}

編輯 C# Script- PlayerInfo, 新增 LateUpdate() 的程式碼:

void LateUpdate()
{
if (target.transform != null)
{
targetPosition = target.transform.position;
targetPosition.y += ballHeight;
this.transform.position =
Camera.main.WorldToScreenPoint(targetPosition) +
screenOffset;
}
}

到此, 我們花了一個小時, 即把 Roll-a-ball 改為多人連線的版本.

我們還會繼續舉辦社群小聚. 若讀著有想瞭解的主題, 請再告知我們, 並歡迎來我們的粉絲團 (https://www.facebook.com/photoncloudtw/) 討論唷 😃

Photon Taiwan

有關多人連線, 即時連線, 遊戲設計的資訊與技術主題~ 多多交流喔!

Ryan Tseng

Written by

Photon Taiwan

有關多人連線, 即時連線, 遊戲設計的資訊與技術主題~ 多多交流喔!

More From Medium

More on Photon from Photon Taiwan

More on Meetup from Photon Taiwan

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade