開發環境配置
Unity版本2022.3
創建Photon賬號以及申請Photon中國區服務
官網申請賬號:Multiplayer Game Development Made Easy Photon Engine
中國區服務: 光子引擎photonengine中文站 成都動聯無限科技有限公司(vibrantlink.com)
導入PUN2插件以及配置
Unity商城地址: PUN 2 - FREE 網絡 Unity Asset Store
導入PUN2的時候會有一個填寫APPID或者郵箱的窗口,跳過就行。
App ID PUN 在申請Photon服務器的時候會有,以及在申請中國區服務的時候郵箱里也會有。
App Version 應該不寫也沒事。
Fixed Region 填寫CN。
修改代碼 Assets/Photon/PhotonRealtime/Code/LoadBalancingClient.cs
將上方一行改為下方一行。
// public string NameServerHost = "ns.photonengine.io";
public string NameServerHost = "ns.photonengine.cn";
構建場景 制作聯網的游戲對象預制體
資源處理
隨便找三張圖湊合一下,將圖片修改為Sprite。由于圖片自帶陰影的問題,樞軸不在棋子中心,稍微調整一下。
調整邊框,改為像素模式,然后將藍色圓圈(樞軸)移動到上下左右四個中心點,觀察右下角X,Y的值,求和除以2即是中心點。
將棋盤棋子放入場景中調整為合適大小,棋子大小0.4看起來還行,通過對齊兩個棋子在棋盤網格上時的樞軸,得出棋盤網格大小為0.35。
其他設置
如果創建的是3D項目,需要改相機的設置。
一定要改為Orthographic,不然鼠標點擊時位置處理會出問題。
游戲場景大小設置為480*800(其他也行,場景中物體的大小適當改改就行,還有坐標計算時的零點會不一樣):
創建Player游戲對象(空對象),黑棋白棋游戲對象(將棋子圖片拖拽進場景即可,前提是已經改為Sprite),將棋子的Order In Layer 設置的比棋盤大,確保棋子渲染在上層,不被遮擋。
給Player,棋子,添加Photon View組件:Player不做處理,棋子額外設置
將Observable Search設置為Manual,并將他們的Transform拖拽到Observed Components中。此設置為同步棋子的transform組件,后邊在生成棋子的時候讓兩邊客戶端的棋子位置一致。
將Player和黑白棋子保存到Resources中保存為預制體(Prefab),存在這個位置才能讓PUN來控制生成需要的游戲對象,并且確保PUN在通知各個客戶端生成游戲對象時物體信息的統一。
核心功能實現
Chess.cs
using UnityEngine;
using Photon.Pun;// 棋子類型枚舉,黑色或白色
public enum ChessType { Black,White}public class Chess : MonoBehaviour
{public int row; // 棋子所在行public int column; // 棋子所在列public ChessType chessType = ChessType.Black; // 棋子類型[PunRPC]public void SetPositionInfo(int[] rowColumn){row = rowColumn[0];column = rowColumn[1];}
}
給棋子添加腳本Chess.cs。我習慣了Chess,準確翻譯應該是Piece。
然后給白棋預制體設置默認為白棋,
Player.cs
給玩家添加Player.cs腳本。如果在場景中給Player添加腳本,記得覆蓋修改到預制體。
玩家落子時通過坐標計算位置,所以需要有一個坐標軸,這里以棋盤左下角為原點,通過之前處理棋子時得知棋盤左下角的坐標是(-2.45,-2.45,0),還有格子寬度為0.35,所以玩家腳本保存這些信息。
首先要確保游戲開始,并且只能控制自己。(這里判斷是否所有人都準備的代碼應該有更合適的寫法,不應該每次update都Find一遍所有Player,似乎可以利用NetWorkManager中的字典,查一個最多只有兩個鍵值對的字典一定比在場景中FindObjectsOfType遍歷一遍所有游戲對象快,懶得改了,算了。)
// 只允許控制自己的玩家對象
if (!photonView.IsMine) return;
// 檢查是否輪到當前玩家回合
if (netWorkManager.playerTurn != chessType) return;
// 驗證所有玩家是否都已準備
var players = GameObject.FindObjectsOfType<Player>();
foreach (var player in players)
{if (player.playerState != PlayerState.Ready) return;
}
// 更新游戲狀態為開始
netWorkManager.gameState = GameState.Start;
// 游戲未開始時禁止操作
if (netWorkManager.gameState != GameState.Start) return;
計算正確的落子位置,(棋子的保存也許應該保存在一個15*15的二維表中,那樣同樣能避免反復執行FindObjectsOfType遍歷所有棋子,棋子中的RPC可以刪掉,然后在Player寫一個RPC同步二維表情況)
// 將屏幕坐標轉換為世界坐標
mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
// 計算相對于棋盤原點的偏移量
offset = mousePos - zeroPosition;// 計算點擊的棋盤行列號
column = (int)Mathf.Round(offset.x / cellWidth); // X軸對應列號
row = (int)Mathf.Round(offset.y / cellWidth); // Y軸對應行號
rowColumn[0] = row; // 存儲為數組[行,列]
rowColumn[1] = column;// 落子合法性驗證 ------------------------
// 邊界檢查(15x15棋盤)
if (row < 0 || row > 14 || column < 0 || column > 14) return;
// 檢查是否已有棋子
chessList = GameObject.FindObjectsOfType<Chess>().ToList();
foreach (var chess in chessList)
{if (chess.row == row && chess.column == column) return;
}// 計算棋子生成的世界坐標
generatePos = new Vector3(column * cellWidth, // X軸位置 = 列號 * 格寬row * cellWidth, // Y軸位置 = 行號 * 格寬 0) + zeroPosition; // 加上棋盤原點偏移// 生成棋子對象 --------------------------
Chess currentChess = null;
if (chessType == ChessType.Black && blackChess != null)
{// 生成黑棋并同步newChess = PhotonNetwork.Instantiate(blackChess.name, generatePos, Quaternion.identity);newChess.GetComponent<PhotonView>().RPC("SetPositionInfo", RpcTarget.All, rowColumn);currentChess = newChess.GetComponent<Chess>();
}
else if (whiteChess != null)
{// 生成白棋并同步newChess = PhotonNetwork.Instantiate(whiteChess.name, generatePos, Quaternion.identity);newChess.GetComponent<PhotonView>().RPC("SetPositionInfo", RpcTarget.All, rowColumn);currentChess = newChess.GetComponent<Chess>();
}
RPC方式同步不同客戶端棋子的行列值,在Player中調用了Chess.cs的代碼來設置棋子所在的行列。
newChess.GetComponent<PhotonView>().RPC("SetPositionInfo", RpcTarget.All, rowColumn);
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using System.Linq;// 玩家狀態枚舉:未準備/已準備
public enum PlayerState
{NotReady,Ready
}public class Player : MonoBehaviour
{// 棋盤相關參數public Vector3 zeroPosition; // 棋盤左下角原點坐標(世界坐標系)public float cellWidth; // 每個棋格寬度(世界單位)public ChessType chessType = ChessType.Black; // 玩家棋子顏色(黑/白)private List<Chess> chessList = new List<Chess>(); // 已放置棋子列表// 網絡組件[HideInInspector]public PhotonView photonView; // 當前玩家對象的PhotonView組件private NetWorkManager netWorkManager; // 場景中的網絡管理器// 棋子放置相關變量private Vector3 generatePos; // 棋子生成位置(世界坐標)private int row; // 當前點擊位置的行號(0-14)private int column; // 當前點擊位置的列號(0-14)private int[] rowColumn = new int[2]; // 行列號的數組形式(用于RPC參數)// 輸入計算相關private Vector3 mousePos; // 鼠標點擊位置(屏幕坐標系)private Vector3 offset; // 點擊位置相對于棋盤原點的偏移量// 棋子預制體public GameObject blackChess; // 黑棋預制體public GameObject whiteChess; // 白棋預制體private GameObject newChess; // 最新生成的棋子對象// 玩家狀態public PlayerState playerState = PlayerState.NotReady; // 玩家準備狀態void Start(){photonView = GetComponent<PhotonView>();netWorkManager = GameObject.FindObjectOfType<NetWorkManager>();// 初始化玩家UI顯示if (photonView.IsMine){netWorkManager.SetSelfText(chessType);}else{netWorkManager.SetHostilefText(chessType);}// 注冊玩家到網絡管理器Photon.Realtime.Player photonPlayer = photonView.Owner;netWorkManager.RegisterPlayer(photonPlayer.ActorNumber, this);}void Update(){// 玩家控制權驗證 --------------------------// 只允許控制自己的玩家對象if (!photonView.IsMine) return;// 檢查是否輪到當前玩家回合if (netWorkManager.playerTurn != chessType) return;// 驗證所有玩家是否都已準備var players = GameObject.FindObjectsOfType<Player>();foreach (var player in players){if (player.playerState != PlayerState.Ready) return;}// 更新游戲狀態為開始netWorkManager.gameState = GameState.Start;// 游戲未開始時禁止操作if (netWorkManager.gameState != GameState.Start) return;// 鼠標點擊處理 ------------------------------if (Input.GetMouseButtonDown(0)){// 坐標轉換計算 --------------------------// 將屏幕坐標轉換為世界坐標mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);// 計算相對于棋盤原點的偏移量offset = mousePos - zeroPosition;// 計算點擊的棋盤行列號column = (int)Mathf.Round(offset.x / cellWidth); // X軸對應列號row = (int)Mathf.Round(offset.y / cellWidth); // Y軸對應行號rowColumn[0] = row; // 存儲為數組[行,列]rowColumn[1] = column;// 落子合法性驗證 ------------------------// 邊界檢查(15x15棋盤)if (row < 0 || row > 14 || column < 0 || column > 14) return;// 檢查是否已有棋子chessList = GameObject.FindObjectsOfType<Chess>().ToList();foreach (var chess in chessList){if (chess.row == row && chess.column == column) return;}// 計算棋子生成的世界坐標generatePos = new Vector3(column * cellWidth, // X軸位置 = 列號 * 格寬row * cellWidth, // Y軸位置 = 行號 * 格寬 0) + zeroPosition; // 加上棋盤原點偏移// 生成棋子對象 --------------------------Chess currentChess = null;if (chessType == ChessType.Black && blackChess != null){// 生成黑棋并同步newChess = PhotonNetwork.Instantiate(blackChess.name, generatePos, Quaternion.identity);newChess.GetComponent<PhotonView>().RPC("SetPositionInfo", RpcTarget.All, rowColumn);currentChess = newChess.GetComponent<Chess>();}else if (whiteChess != null){// 生成白棋并同步newChess = PhotonNetwork.Instantiate(whiteChess.name, generatePos, Quaternion.identity);newChess.GetComponent<PhotonView>().RPC("SetPositionInfo", RpcTarget.All, rowColumn);currentChess = newChess.GetComponent<Chess>();}// 游戲邏輯處理 --------------------------// 播放落子音效netWorkManager.GetComponent<PhotonView>().RPC("PlayMarkingAudio", RpcTarget.All);// 勝負判定chessList = GameObject.FindObjectsOfType<Chess>().ToList();bool isFive = JudgeFiveChess(chessList, currentChess);if (isFive){// 游戲結束處理netWorkManager.GetComponent<PhotonView>().RPC("GameOver", RpcTarget.All, currentChess.chessType);netWorkManager.GetComponent<PhotonView>().RPC("ReSetGame", RpcTarget.All);return;}// 切換回合netWorkManager.GetComponent<PhotonView>().RPC("ChangeTurn", RpcTarget.All);}}/// <summary>/// 獲取當前玩家的可序列化狀態數據(用于網絡同步)/// </summary>/// <returns>/// 返回包含玩家關鍵狀態的對象數組:/// [0] ChessType - 棋子顏色類型/// [1] PlayerState - 玩家準備狀態/// </returns>public object[] GetPlayerState(){return new object[] {chessType, // 玩家當前棋子顏色(Black/White)playerState // 玩家準備狀態(NotReady/Ready)};}/// <summary>/// [PunRPC] 設置玩家棋子類型(網絡同步方法)/// </summary>[PunRPC]public void SetChessType(ChessType type){chessType = type; // 設置當前玩家棋子顏色}/// <summary>/// [PunRPC] 設置玩家準備狀態(網絡同步方法)/// </summary>/// <remarks>/// 執行流程:/// 1. 修改本地玩家準備狀態/// 2. 更新Photon網絡自定義屬性(自動同步到其他客戶端)/// 3. 更新本地和對手的UI顯示/// </remarks>[PunRPC]public void SetPlayerReady(){// 修改本地狀態為已準備playerState = PlayerState.Ready;// 設置Photon自定義屬性(自動同步到所有客戶端)ExitGames.Client.Photon.Hashtable props = new ExitGames.Client.Photon.Hashtable{{ "IsReady", true } // 使用Hashtable鍵值對存儲準備狀態};PhotonNetwork.LocalPlayer.SetCustomProperties(props); // 觸發OnPlayerPropertiesUpdate回調// 更新UI顯示if (photonView.IsMine){// 更新自己的準備狀態顯示netWorkManager.selfReadyText.text = "已準備";}else{// 更新對手的準備狀態顯示netWorkManager.hostileReadyText.text = "已準備";}}/// <summary>/// 判斷是否形成五子連珠(核心勝負判定算法)/// </summary>/// <param name="chessList">棋盤上所有棋子列表</param>/// <param name="currentChess">當前剛落下的棋子</param>/// <returns>true表示五子連珠達成,false表示未達成</returns>/// <remarks>/// 檢測邏輯:/// 1. 篩選出當前玩家顏色的所有棋子/// 2. 從當前棋子出發,向8個方向遞歸檢測連續棋子/// 3. 合并相反方向的棋子數量,判斷是否達到5連/// </remarks>bool JudgeFiveChess(List<Chess> chessList, Chess currentChess){bool result = false;// 篩選當前玩家顏色的棋子(優化點:可緩存此列表避免重復篩選)List<Chess> currentChessTypeList = chessList.Where(en => en.chessType == chessType).ToList();// 八方向檢測(實際只需四個軸線方向的檢測)List<Chess> upList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.Up); // 正上方List<Chess> downList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.Down); // 正下方List<Chess> leftList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.Left); // 正左方List<Chess> rightList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.Right);// 正右方List<Chess> leftUpList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.LeftUp); // 左上方List<Chess> rightDownList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.RightDown);// 右下方List<Chess> leftDownList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.LeftDown); // 左下方List<Chess> rightUpList = GetSameChessByDirection(currentChessTypeList, currentChess, ChessDirection.RightUp); // 右上方// 四軸線勝負判定(垂直/水平/左上右下斜線/左下右上斜線)if (upList.Count + downList.Count + 1 >= 5 || // 垂直方向(當前棋子+上方+下方)leftList.Count + rightList.Count + 1 >= 5 || // 水平方向(當前棋子+左方+右方)leftUpList.Count + rightDownList.Count + 1 >= 5 || // 主斜線(當前棋子+左上方+右下方)leftDownList.Count + rightUpList.Count + 1 >= 5) // 副斜線(當前棋子+左下方+右上方){result = true;}return result;}/// <summary>/// 遞歸獲取指定方向上的連續同色棋子(深度優先搜索)/// </summary>/// <param name="currentChessTypeList">當前玩家顏色的所有棋子</param>/// <param name="currentChess">當前檢測的基準棋子</param>/// <param name="direction">檢測方向(八方向枚舉)</param>/// <returns>沿指定方向的連續棋子列表(不包含當前棋子)</returns>/// <remarks>/// 實現原理:/// 1. 根據方向參數確定相鄰棋子的行列偏移量/// 2. 遞歸檢測相鄰棋子的相鄰棋子/// 3. 注意:遞歸深度最大為4層(五子棋規則)/// </remarks>List<Chess> GetSameChessByDirection(List<Chess> currentChessTypeList, Chess currentChess, ChessDirection direction){List<Chess> result = new List<Chess>();switch (direction){case ChessDirection.Up: // 正上方檢測(行號+1,列號不變)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row + 1 && item.column == currentChess.column){result.Add(item);// 遞歸檢測更上方的棋子result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.Up));}}break;case ChessDirection.Down: // 正下方檢測(行號-1,列號不變)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row - 1 && item.column == currentChess.column){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.Down));}}break;case ChessDirection.Left: // 正左方檢測(列號-1,行號不變)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row && item.column == currentChess.column - 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.Left));}}break;case ChessDirection.Right: // 正右方檢測(列號+1,行號不變)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row && item.column == currentChess.column + 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.Right));}}break;case ChessDirection.LeftUp: // 左上方檢測(行號+1,列號-1)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row + 1 && item.column == currentChess.column - 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.LeftUp));}}break;case ChessDirection.RightDown: // 右下方檢測(行號-1,列號+1)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row - 1 && item.column == currentChess.column + 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.RightDown));}}break;case ChessDirection.LeftDown: // 左下方檢測(行號-1,列號-1)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row - 1 && item.column == currentChess.column - 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.LeftDown));}}break;case ChessDirection.RightUp: // 右上方檢測(行號+1,列號+1)foreach (Chess item in currentChessTypeList){if (item.row == currentChess.row + 1 && item.column == currentChess.column + 1){result.Add(item);result.AddRange(GetSameChessByDirection(currentChessTypeList, item, ChessDirection.RightUp));}}break;}return result;}
}// 棋子尋找方向
public enum ChessDirection
{Up,Down,Left,Right,LeftUp,RightDown,LeftDown,RightUp
}
NetWorkManager.cs
場景中創建空對象,命名為NetWokManager并添加NetWokManager.cs。
編寫NetWokManager.cs,繼承于MonoBehaviourPunCallbacks
在NetWorkManager中生成玩家:通過RPC的方式設置玩家的棋子類型,能讓兩邊客戶端同步,如果使用 newPlayer.GetComponent<Player>``().chessType = ChessType.Black;
這種方式設置,只會在本地設置棋子類型。
比如當前默認玩家棋子類型是黑棋,A創建并進入房間后,將自己設置為黑棋,B進入房間后將自己設置為白棋。
在A客戶端,B依舊是默認的黑棋,因為B修改自身棋子類型只在本地運行,
在B客戶端,A是默認的黑棋,B是設置后的白棋,B客戶端看起來正確只是因為剛好默認的是對的,而A客戶端是錯誤的。
而RPC方式調用,參數RpcTarget.All,是讓所有客戶端都執行 SetChessType
方法,所以這里其實主要是后進來的玩家需要通過RPC讓兩邊都執行 SetChessType
方法,將后來的玩家設置為白棋。
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;
using System.Linq;/// <summary>
/// 游戲狀態枚舉(對應游戲流程階段)
/// </summary>
public enum GameState
{Ready = 1, // 準備階段(玩家未準備)Start = 2, // 游戲進行階段(雙方已準備)GameOver = 3 // 游戲結束階段(已分出勝負)
}/// <summary>
/// 網絡管理核心類(繼承Photon回調接口)
/// 功能:處理網絡連接、玩家管理、游戲狀態同步
/// </summary>
public class NetWorkManager : MonoBehaviourPunCallbacks
{// 玩家預制體配置public GameObject player; // 玩家角色預制體(需提前拖拽賦值)// 游戲回合控制public ChessType playerTurn = ChessType.Black; // 當前回合玩家棋子類型(默認黑方先手)// 游戲狀態管理public GameState gameState = GameState.Ready; // 當前游戲狀態// UI組件綁定public TextMeshProUGUI readyText; // 準備按鈕文本組件public TextMeshProUGUI selfChessText; // 顯示本機玩家棋子類型的文本public TextMeshProUGUI selfReadyText; // 本機玩家準備狀態文本public TextMeshProUGUI hostileChessText; // 顯示對手棋子類型的文本public TextMeshProUGUI hostileReadyText; // 對手準備狀態文本public TextMeshProUGUI turnText; // 回合提示文本public TextMeshProUGUI gameOverText; // 游戲結束提示文本public TextMeshProUGUI winText; // 勝利者顯示文本// 音效組件public AudioSource markingAudio; // 落子音效組件// 玩家映射表(維護Photon玩家與本地Player實例的關系)// Key: Photon Player的ActorNumber, Value: 對應的MyGame.Player實例private Dictionary<int, Player> photonPlayerToLocalPlayer = new Dictionary<int, Player>();/// <summary>/// 注冊玩家映射關系,用于當生成玩家對象時記錄映射/// </summary>/// <param name="actorNumber">Photon玩家的唯一標識</param>/// <param name="localPlayer">對應的本地Player實例</param>public void RegisterPlayer(int actorNumber, Player localPlayer){photonPlayerToLocalPlayer[actorNumber] = localPlayer;}void Start(){SetUIState(); // 初始化UI狀態PhotonNetwork.ConnectUsingSettings(); // 連接到Photon云服務}/// <summary>/// 成功連接Photon主服務器回調/// 觸發時機:完成網絡握手并連接到區域服務器/// </summary>public override void OnConnectedToMaster(){base.OnConnectedToMaster();Debug.Log("成功連接至Photon主服務器");// 配置房間參數RoomOptions roomOptions = new RoomOptions();roomOptions.MaxPlayers = 2; // 設置最大玩家數為2(五子棋雙人對戰)// 加入或創建房間(房間名"WuZiQi",類型為默認大廳)PhotonNetwork.JoinOrCreateRoom("WuZiQi", roomOptions, TypedLobby.Default);}/// <summary>/// 成功加入房間回調/// 觸發時機:本地玩家加入指定房間后/// </summary>public override void OnJoinedRoom(){base.OnJoinedRoom();Debug.Log("成功加入房間,當前房間人數:" + PhotonNetwork.CurrentRoom.PlayerCount);// 安全檢查:確保玩家預制體已配置if (player == null){Debug.LogError("玩家預制體未配置!");return;}// 實例化網絡玩家對象(在所有客戶端同步生成)GameObject newPlayer = PhotonNetwork.Instantiate(player.name, Vector3.zero, Quaternion.identity);// 房主設置棋子顏色并同步if (PhotonNetwork.IsMasterClient){// 使用RPC同步黑棋設置(All表示所有客戶端執行)newPlayer.GetComponent<PhotonView>().RPC("SetChessType",RpcTarget.All,ChessType.Black);}else{// 非房主設置白棋newPlayer.GetComponent<PhotonView>().RPC("SetChessType",RpcTarget.All,ChessType.White);}}/// <summary>/// [PunRPC] 切換回合控制權(網絡同步方法)/// </summary>/// <remarks>/// 在所有客戶端同步更新回合狀態和UI提示/// </remarks>[PunRPC]public void ChangeTurn(){// 切換當前回合玩家顏色playerTurn = playerTurn == ChessType.Black ? ChessType.White : ChessType.Black;// 更新回合提示文本turnText.text = playerTurn == ChessType.Black ? "請黑方落子" : "請白方落子";}/// <summary>/// [PunRPC] 游戲結束處理(網絡同步方法)/// </summary>/// <param name="winChessType">勝利方棋子顏色</param>/// <remarks>/// 會在所有客戶端顯示游戲結束界面/// </remarks>[PunRPC]public void GameOver(ChessType winChessType){// 更新游戲狀態gameState = GameState.GameOver;// 安全檢查UI組件引用if (gameOverText){// 顯示游戲結束界面gameOverText.gameObject.SetActive(true);gameOverText.text = "Game Over";// 設置勝利者文本winText.text = winChessType == ChessType.Black ? "黑方獲勝" : "白方獲勝";}}/// <summary>/// [PunRPC] 重置游戲到初始狀態(網絡同步方法)/// </summary>/// <remarks>/// 1. 重置UI狀態/// 2. 重置所有玩家準備狀態/// 3. 恢復默認回合順序/// </remarks>[PunRPC]public void ReSetGame(){// 重置準備相關UIreadyText.text = "準備";selfReadyText.text = "未準備";hostileReadyText.text = "未準備";// 重置所有玩家狀態List<Player> players = GameObject.FindObjectsOfType<Player>().ToList();foreach (Player p in players){p.playerState = PlayerState.NotReady;}// 恢復初始回合設置playerTurn = ChessType.Black;turnText.text = "請黑方落子";}/// <summary>/// [PunRPC] 播放落子音效(網絡同步方法)/// </summary>/// <remarks>/// 所有客戶端同步播放音效/// </remarks>[PunRPC]public void PlayMarkingAudio(){if (markingAudio == null) return; // 空值保護markingAudio.Play(); // 播放音效文件}/// <summary>/// 準備按鈕點擊事件處理/// </summary>/// <remarks>/// 執行流程:/// 1. 防止重復準備/// 2. 更新本地UI/// 3. 網絡同步準備狀態/// 4. 重置游戲狀態/// </remarks>public void OnClickReadyButton(){// 防止重復點擊if (readyText.text == "已準備") return;// 更新準備按鈕狀態readyText.text = "已準備";// 遍歷所有玩家對象var players = GameObject.FindObjectsOfType<Player>();foreach (Player p in players){// 只同步當前客戶端的玩家狀態if (p.GetComponent<PhotonView>().IsMine){p.GetComponent<PhotonView>().RPC("SetPlayerReady", RpcTarget.All);}}// 更新游戲狀態gameState = GameState.Ready;// 隱藏結束界面gameOverText.gameObject.SetActive(false);// 清理棋盤上所有棋子List<Chess> chessList = GameObject.FindObjectsOfType<Chess>().ToList();foreach (Chess chess in chessList){GameObject.Destroy(chess.gameObject);}}/// <summary>/// 初始化所有UI狀態/// </summary>public void SetUIState(){// 準備相關控件readyText.text = "準備";selfChessText.text = "";selfReadyText.text = "";// 對手信息控件hostileChessText.text = "";hostileReadyText.text = "";// 游戲進程控件turnText.text = "請黑方落子";gameOverText.gameObject.SetActive(false);winText.text = "";}/// <summary>/// 設置本機玩家信息顯示/// </summary>/// <param name="chessType">當前玩家的棋子顏色</param>public void SetSelfText(ChessType chessType){selfChessText.text = chessType == ChessType.Black ? "黑方" : "白方";selfReadyText.text = "未準備";}/// <summary>/// 設置對手玩家信息顯示/// </summary>/// <param name="chessType">對手玩家的棋子顏色</param>public void SetHostilefText(ChessType chessType){hostileChessText.text = chessType == ChessType.Black ? "黑方" : "白方";hostileReadyText.text = "未準備";}/// <summary>/// 當有新玩家加入房間時的回調方法(Photon網絡事件)/// </summary>/// <param name="newPhotonPlayer">新加入的Photon玩家對象</param>/// <remarks>/// 核心功能:房主向新玩家同步現有玩家的狀態/// 執行邏輯:/// 1. 僅由房主執行同步操作/// 2. 遍歷除新玩家外的所有已有玩家/// 3. 通過ActorNumber映射找到對應的本地Player實例/// 4. 向新玩家定向發送同步數據/// </remarks>public override void OnPlayerEnteredRoom(Photon.Realtime.Player newPhotonPlayer){base.OnPlayerEnteredRoom(newPhotonPlayer);if (PhotonNetwork.IsMasterClient){// 遍歷房間內所有Photon玩家(包括自己)foreach (Photon.Realtime.Player photonPlayer in PhotonNetwork.PlayerList){// 跳過新加入的玩家(只需要同步現有玩家狀態)if (photonPlayer != newPhotonPlayer){// 通過Photon的ActorNumber查找對應的游戲內Player對象if (photonPlayerToLocalPlayer.TryGetValue(photonPlayer.ActorNumber, out Player localPlayer)){// 向新玩家定向發送RPC(僅限新玩家接收)photonView.RPC("SyncPlayerState",newPhotonPlayer, // 指定接收者:新玩家photonPlayer.ActorNumber, // 目標玩家的唯一網絡標識localPlayer.chessType, // 玩家棋子顏色狀態localPlayer.playerState // 玩家準備狀態);}}}}}/// <summary>/// [PunRPC] 同步玩家狀態數據(網絡同步方法)/// </summary>/// <param name="targetActorNumber">目標玩家的Photon ActorNumber</param>/// <param name="chessType">需要同步的棋子顏色</param>/// <param name="playerState">需要同步的準備狀態</param>/// <remarks>/// 核心功能:根據網絡同步數據更新本地玩家狀態和UI/// 注意:由于是定向發送,該方法只會在新加入的客戶端執行/// </remarks>[PunRPC]private void SyncPlayerState(int targetActorNumber, ChessType chessType, PlayerState playerState){// 通過ActorNumber查找對應的本地Player實例if (photonPlayerToLocalPlayer.TryGetValue(targetActorNumber, out Player targetPlayer)){// 更新本地玩家眼中,其他玩家的狀態targetPlayer.chessType = chessType;targetPlayer.playerState = playerState;// 更新UI,其實已經排除自己了,targetPlayer一定不會是自己if (targetPlayer.photonView.IsMine){// 更新本機玩家UIselfChessText.text = chessType == ChessType.Black ? "黑方" : "白方";selfReadyText.text = playerState == PlayerState.NotReady ? "未準備" : "已準備";}else{// 更新對手玩家UIhostileChessText.text = chessType == ChessType.Black ? "黑方" : "白方";hostileReadyText.text = playerState == PlayerState.NotReady ? "未準備" : "已準備";}}}/// <summary>/// 當有玩家離開房間時的回調方法(Photon網絡事件)/// </summary>/// <param name="otherPlayer">離開的Photon玩家對象</param>/// <remarks>/// 維護映射關系:及時清理已離開玩家的數據/// 防止后續操作訪問到無效玩家引用/// </remarks>public override void OnPlayerLeftRoom(Photon.Realtime.Player otherPlayer){// 從映射字典中移除離開的玩家photonPlayerToLocalPlayer.Remove(otherPlayer.ActorNumber);}/// <summary>/// 當房主切換時的回調方法(Photon網絡事件)/// </summary>/// <param name="newMaster">新任房主的Photon玩家對象</param>/// <remarks>/// 特殊處理:新房主需要重新同步所有玩家狀態/// 當前實現策略:/// 1. 新房主遍歷本地維護的玩家映射表/// 2. 向其他客戶端同步每個玩家的最新狀態/// 優化建議:可在此處實現換先手邏輯(當前未實現)/// </remarks>public override void OnMasterClientSwitched(Photon.Realtime.Player newMaster){if (PhotonNetwork.IsMasterClient){// 遍歷本地維護的所有玩家映射關系foreach (var kvp in photonPlayerToLocalPlayer){// 向其他客戶端同步玩家狀態photonView.RPC("SyncPlayerState",RpcTarget.Others, // 發送給除自己外的其他玩家kvp.Key, // ActorNumberkvp.Value.chessType, // 棋子顏色kvp.Value.playerState// 準備狀態);}}}
}
使用PhotonNetwork.Instantiate生成游戲對象,會在所有客戶端都生成,確保同步。
如果要測試創建情況,將以下內容設置好后,構建并啟動一個程序并且運行Unity編輯器中的場景,觀察Player的創建情況。分別啟動Editor版本+Build版本,可以更好的在Editor中觀察運行情況,更好發現哪里有問題。
此時應該有兩個Player(Clone)
踩坑經驗總結
問題現象
- 每次點擊會生成兩個棋子
- 棋子位置固定,不跟隨鼠標點擊位置
- 玩家準備狀態不同步
原因分析
-
多Player實例沖突
- 場景中預先放置了一個靜態Player對象,而Photon網絡又動態生成了一個Player。
- 兩個Player的腳本同時運行,導致每次點擊觸發兩次棋子生成。
-
相機投影模式錯誤
- Unity默認的3D項目使用 透視模式(Perspective) ,導致
ScreenToWorldPoint
坐標轉換時Z軸計算錯誤。 - 棋子實際生成在遠離棋盤的3D空間中(比如Z軸位置不對),看起來像位置固定。
- Unity默認的3D項目使用 透視模式(Perspective) ,導致
-
準備狀態僅在本地生效,其他客戶端無法感知
- 新玩家加入時未獲取房間內已有玩家的準備狀態
- 房主準備并同步信息時新玩家并不存在,新玩家到來時,房主的同步行為已經結束,新玩家看到的是默認狀態
解決方案
1. 網絡對象管理:一人一角色
- 操作:
- 刪除場景中預先放置的Player對象,其實是忘記刪了。嘿嘿
- 改為 完全通過Photon動態生成Player (在代碼中調用
PhotonNetwork.Instantiate
)。
2. 相機設置:切換正交模式
- 操作:
- 將相機
Projection
從Perspective
(透視)改為Orthographic
(正交)。 - 調整相機
Size
值,確保整個棋盤可見。
- 將相機
- 原理:
正交模式忽略Z軸深度,鼠標坐標轉換更簡單直觀(類似2D游戲)。
3.使用字典將玩家實例(Photon.Realtime.Player)的ActorNumber與Player腳本對應映射,存儲準備狀態用于同步
-
// 玩家映射表(維護Photon玩家與本地Player實例的關系) // Key: Photon Player的ActorNumber, Value: 對應的MyGame.Player實例 private Dictionary<int, Player> photonPlayerToLocalPlayer = new Dictionary<int, Player>();
- 在
OnPlayerEnteredRoom
中由房主遍歷并同步所有玩家狀態 -
// 向新玩家定向發送RPC(僅限新玩家接收) photonView.RPC("SyncPlayerState",newPhotonPlayer, // 指定接收者:新玩家photonPlayer.ActorNumber, // 目標玩家的唯一網絡標識localPlayer.chessType, // 玩家棋子顏色狀態localPlayer.playerState // 玩家準備狀態 );
參考鏈接
從零手寫Unity3D經典游戲五子棋開發+Photon網絡聯機游戲_嗶哩嗶哩_bilibili
倉庫地址
MapleInori/WuZiQi: 學習記錄 (github.com)
寫文檔真難啊,有點寫不明白。