Related Posts Plugin for WordPress, Blogger...

6-26 Sending Event To Instantiate Player GameObject When Player Login

上一章已經介紹如何上傳玩家位置的方法了,接下來,當其他玩家登錄遊戲的時候,也應該要在遊戲場景中實例化新的玩家物件。首先,介紹要實作的流程會讓大家感到比較清楚:

1. 當玩家登錄時,發送Request,要求取得其他玩家的資訊。
2. Server發送Response,將目前線上玩家的所有資訊回傳。
3. 玩家接收到Response後,依照線上玩家的資訊列表,產生玩家的GameObject物件。
4. Server發送Event給其他線上玩家,告知有新的玩家上線。
5. 其他線上玩家接收到Event,於場景中產生一個新的玩家的GameObject。

上述便是此次要實作的流程。

首先,在PhotonServer端的MyClientPeer,新增變數,用來記錄Account與x, y, z位置資訊。當玩家與PhotonServer連線時,便會產生一個ClientPeer物件,所以直接將該名玩家的資訊記錄在ClientPeer物件中。

接著,在SyncPositionHandler.cs中的OnOperationRequest,把來自Client端的位置資訊儲存到ClientPeer的變數中。

同時,也要在LoginHandler中把來自Client端的Account,儲存到ClientPeer的變數。

接著我們回到Unity端來實作SyncOtherPlayerRequest.cs,這個類別的作用是初始化場景時,負責發送Request,要求取得其他玩家的資訊。

所以在OnOperationResponse中,會從Server端取得string陣列,裡面儲存已登入玩家的帳號。這邊,我呼叫了SyncPosition類別的AddNewPlayer方法,待會會告訴大家如何撰寫。
另外提醒,PhotonServer預設支援可自動序列化的類型,或者如何實作自定義類別的序列化,請參考下列網址:
https://doc.photonengine.com/en-us/onpremise/current/reference/serialization-in-photon

現在來到SyncPosition.cs,我新增了一個Dictionary管理登入的線上玩家(不包含自己)。

大家可以看到AddNewPlayer中,先從onlinePlayerList確定這個帳號不在列表中,然後使用Instantiate產生GameObject,並將PlayerMovement類別存入onlinePlayerList中,以後同步位置時需要使用。

接著我們替這次的Request增加一個OperationCode,名為SyncOtherPlayerPosition。

來到場景中的SyncPosition物件,將SyncOtherPlayerRequest.cs加進去,並記得選擇正確的OperationCode。

接下來,回到PhotonServer端,讓我們來處理SyncOtherPlayerHandler.cs。

再次說明,本次的Request需要將線上玩家的清單傳回給Client。所以,在OnOperationRequest方法中,我們需要從PeerList中剔除目前正在登錄的自己,然後將其他線上玩家的清單傳回去。

處理完Request後,Server還必須發送Event給其他線上玩家,告知有新的玩家上線。

於是,我們要再回到Unity端,處理來自Server發送的Event。同Request的做法一樣,我們使用Dictionary統一管理不同功用的Event,所有Event的處理都會繼承我自定義的BaseEvent類別。

在PhotonEngine.cs中提供AddEvent跟RemoveEvent,將事件加入到Dictionary中。

然後在OnEvent方法中,藉由EventCode從Dictionary中取得正確的Event物件,並處理OnEventData方法。

BaseEvent.cs的寫法與Request.cs相似。若不懂我用的這個架構,請參考之前的文章:
6-14 Using Dictionary To Manage All Request
https://3dactionrpg.blogspot.com/2018/05/6-14-using-dictionary-to-manage-all.html


接著新增NewPlayerEvent.cs,繼承BaseEvent,在OnEventData中處理來自Server的訊息。在流程中,要處理其他線上玩家登入遊戲的情況,所以需要於場景中產生一個新的玩家的GameObject

撰寫完成後,記得將NewPlayerEvent.cs放進場景中的SyncPosition。


此時開啟遊戲測試看看,要從登入畫面進入遊戲。首先輸入帳號aaa。

接著使用其他的設備進行登入,使用帳號bbb。

場景中確實產生了第二個玩家,但是不知道為什麼腳都陷進地面底下了。

看了一下Console,發現有錯誤訊息。
Failed to create agent because it is not close enough to the NavMesh

經查詢後,似乎是於Awake階段,透過AddComponent新增NavMeshAgent,會出現這個問題,而且還僅限於Instatiate的時候。我原本放在場景中的Player就一點事情都沒有。為了解決這個問題,只好修改我的Character.cs,將AddComponent<NavMeshAgent>放到Start方法中。
另外,我也有調查網路上針對此問題的其他解決方案,但對我來說都沒有效果,大家參考看看。
https://forum.unity.com/threads/failed-to-create-agent-because-it-is-not-close-enough-to-the-navmesh.125593/

https://forum.unity.com/threads/failed-to-create-agent-because-it-is-not-close-enough-to-the-navmesh.500553/

以下提供本次修改的程式碼:
以下提供Client端原始碼:
PhotonEngine.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using ExitGames.Client.Photon;

public class PhotonEngine : MonoBehaviour, IPhotonPeerListener {

 private Dictionary requestDict = new Dictionary ();
 private Dictionary eventDict = new Dictionary ();

 PhotonPeer peer;
 public static PhotonEngine Instance;
 public static string userAccount;

 void Awake() {
  if (Instance == null) {
   Instance = this;
   DontDestroyOnLoad (this);
  } else if (Instance != this) {
   Destroy (gameObject);
   return;
  }
 }

 void Start () {
  // 透過Listener回應伺服器的Response
  peer = new PhotonPeer (this, ConnectionProtocol.Udp);
  peer.Connect ("anoneko.cloudapp.net:5055", "NoliahFantasyServer");
 }
 
 void Update () {
  // 必須在Update一直呼叫Service方法才能持續連線到Photon
  peer.Service ();
 }

 void OnDestroy(){
  if (peer != null && peer.PeerState == PeerStateValue.Connected) {
   peer.Disconnect ();
  }
 }

 public PhotonPeer GetPeer(){
  return peer;
 }

 public void AddRequest(Request request){
  requestDict.Add (request.operationCode, request);
 }

 public void RemoveRequest(Request request){
  requestDict.Remove (request.operationCode);
 }

 public void AddEvent(BaseEvent baseEvent){
  eventDict.Add (baseEvent.eventCode, baseEvent);
 }

 public void RemoveEvent(BaseEvent baseEvent){
  eventDict.Remove(baseEvent.eventCode);
 }

 #region IPhotonPeerListener implementation

 public void DebugReturn (DebugLevel level, string message)
 {
 }

 // 接收由Server端傳回的Response
 public void OnOperationResponse (OperationResponse operationResponse)
 {
  OperationCode opCode = (OperationCode)operationResponse.OperationCode;
  Request request = null;
  requestDict.TryGetValue (opCode, out request);
  request.OnOperationResponse (operationResponse);
 }

 public void OnStatusChanged (StatusCode statusCode)
 {
 }

 // 接收由Server端發送的Event事件
 public void OnEvent (EventData eventData)
 {
  EventCode eventCode = (EventCode)eventData.Code;
  BaseEvent baseEvent = null;
  eventDict.TryGetValue (eventCode, out baseEvent);
  baseEvent.OnEventData (eventData);
 }

 #endregion
}


SyncPosition.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using RPG.Character;
using RPG.Core;

public class SyncPosition : MonoBehaviour {

 [SerializeField] GameObject playerPrefab;

 GameObject player;
 SyncPositionRequest syncPosiRequest;
 SyncOtherPlayerRequest syncOtherPlayerRequest;
 Vector3 lastPosition;
 Dictionary onlinePlayerList;

 void Awake(){
  onlinePlayerList = new Dictionary ();
 }

 void Start () {
  syncPosiRequest = GetComponent();
  syncOtherPlayerRequest = GetComponent ();
  player = Game.Instance.playerMovement.gameObject;
  // 初始化Player位置
  lastPosition = player.transform.position;
  // 每段時間同步玩家位置
  StartCoroutine (UploadPosition ());
  // 同步其他玩家位置
  StartCoroutine (SyncOtherPlayerPosition ());
 }

 IEnumerator UploadPosition(){
  while (true) {
   // 每秒同步五次
   yield return new WaitForSeconds (0.2f);
   // 判斷是否有移動,若Player沒有在移動便不需要同步
   Vector3 nowPosition = player.transform.position;
   if(Vector3.Distance(nowPosition, lastPosition) > 0.1f){
    lastPosition = nowPosition;
    syncPosiRequest.position = nowPosition;
    // 發送位置更新訊息
    syncPosiRequest.OnDefaultRequest ();
   }
  }
 }

 public void AddNewPlayer(string account){
  if (onlinePlayerList.ContainsKey (account) == false) {
   // TODO 製作Player Factory改善這個問題
   // Position使用此遊戲物件的位置
   GameObject player = Instantiate (playerPrefab, transform.position, Quaternion.identity);
   onlinePlayerList.Add (account, player.GetComponent());
  }
 }

 IEnumerator SyncOtherPlayerPosition(){
  yield return new WaitForSeconds (0.2f);
  syncOtherPlayerRequest.OnDefaultRequest ();
 }


}


OperationCode.cs:

public enum OperationCode : byte
{
 Login,
 Signup,
 SyncPosition,
 SyncOtherPlayerPosition
}

ParameterCode.cs:

public enum ParameterCode : byte
{
 Acccount,
 Password,
 Position,
 x,
 y,
 z,
 AccountList
}

EventCode.cs:

public enum EventCode : byte
{
 NewPlayer
}


BaseEvent.cs:

using System;
using UnityEngine;
using ExitGames.Client.Photon;

public abstract class BaseEvent : MonoBehaviour
{
 public EventCode eventCode;

 public abstract void OnEventData(EventData eventData);

 public virtual void Start(){
  PhotonEngine.Instance.AddEvent (this);
 }

 public void Destory(){
  PhotonEngine.Instance.RemoveEvent (this);
 }
}



NewPlayerEvent.cs:

using System;
using ExitGames.Client.Photon;

/// 
/// 當Server傳來Event告知有其他Player登入場景,需要在場景添加Player物件的Event
/// 
public class NewPlayerEvent : BaseEvent
{
 SyncPosition syncPosition;

 void Awake(){
  syncPosition = GetComponent ();
 }

 public override void OnEventData (EventData eventData)
 {
  print ("處理來自Server的NewPlayerEvent");
  object account;
  eventData.Parameters.TryGetValue ((byte)ParameterCode.Acccount, out account);
  // 通知SyncPosition產生其他的Player物件
  syncPosition.AddNewPlayer(account.ToString());
 }
}



Character.cs:

using System;
using UnityEngine;
using UnityEngine.AI;
using RPG.CameraUI;

namespace RPG.Character{
 [SelectionBase]
 public class Character : MonoBehaviour
 {
  [Header("Audio")]
  [Range(0.0f, 1.0f)][SerializeField] float volume = 1f;

  [Header("Capsule Collider")]
  [SerializeField] Vector3 colliderCenter = new Vector3(0, 1, 0);
  [SerializeField] float colliderRadius = 0.3f;
  [SerializeField] float colliderHeight = 1.5f;

  [Header("Animator")]
  [SerializeField] RuntimeAnimatorController animatorController;
  [SerializeField] AnimatorOverrideController animatorOverrideController;
  [SerializeField] Avatar avatar;
  [Range(0.0f, 1.0f)][SerializeField] float animatorForwardCap = 1f;

  [Header("Movement")]
  [SerializeField] float moveSpeedMultiplier = 1f;
  [SerializeField] float animationSpeedMultiplier = 1f;
  [SerializeField] float movingTurnSpeed = 1800;
  [SerializeField] float stationaryTurnSpeed = 1800;
  [SerializeField] float moveThreshold = 1f;

  [Header("Nav Mesh Agent")]
  [SerializeField] float steeringSpeed = 1.0f;

  NavMeshAgent navMeshAgent;
  Animator animator;
  Rigidbody myRigidbody;
  CapsuleCollider capsuleCollider;
  float turnAmount;
  float forwardAmount;
  bool isAlive = true;

  void Awake(){
   AddRequiredComponents ();
  }

  private void AddRequiredComponents(){
   capsuleCollider = gameObject.AddComponent ();
   capsuleCollider.center = colliderCenter;
   capsuleCollider.radius = colliderRadius;
   capsuleCollider.height = colliderHeight;

   myRigidbody = gameObject.AddComponent ();
   myRigidbody.collisionDetectionMode = CollisionDetectionMode.Continuous;
   myRigidbody.constraints = RigidbodyConstraints.FreezeRotation;

   AudioSource audioSource = gameObject.AddComponent ();
   audioSource.volume = volume;

   animator = gameObject.AddComponent ();
   animator.runtimeAnimatorController = animatorController;
   animator.updateMode = AnimatorUpdateMode.AnimatePhysics;
   animator.avatar = avatar;
  }

  void Start(){
   // 當Instatiate時,NavMeshAgent在Awake階段進行AddComponent
   // 會產生下列Bug,故移動到Start內
   // Failed to create agent because it is not close enough to the NavMesh
   navMeshAgent = gameObject.AddComponent ();
   navMeshAgent.updateRotation = false;
   navMeshAgent.updatePosition = true;
   navMeshAgent.speed = steeringSpeed;
  }

  void Update(){
   if (isAlive && navMeshAgent.remainingDistance > navMeshAgent.stoppingDistance) {
    Move(navMeshAgent.desiredVelocity);
   } else {
    Move (Vector3.zero);
   }
  }

  void Move(Vector3 movement)
  {
   SetForwardAndTurn (movement);
   ApplyExtraTurnRotation();
   UpdateAnimator();
  }

  public float GetAnimSpeedMultiplier(){
   return animator.speed;
  }

  public void Kill(){
   isAlive = false;
  }

  public void SetDestination(Vector3 worldPos){
   if (isAlive) {
    navMeshAgent.SetDestination (worldPos);
   }
  }

  public void SetStopDistance(float stoppingDistance){
   if (isAlive) {
    navMeshAgent.stoppingDistance = stoppingDistance;
   }
  }

  public AnimatorOverrideController GetOverrideController(){
   return animatorOverrideController;
  }

  private void SetForwardAndTurn(Vector3 movement){
   // 將Wolrd相對的向量轉換成Local相對的向量
   if (movement.magnitude > moveThreshold) {
    movement.Normalize ();
   }
   // 將Worldspace的方向轉換成LocalSpace
   Vector3 localMovement = transform.InverseTransformDirection(movement);
   turnAmount = Mathf.Atan2(localMovement.x, localMovement.z);
   forwardAmount = localMovement.z;
  }

  void UpdateAnimator()
  {
   // update the animator parameters
   animator.SetFloat("Forward", forwardAmount * animatorForwardCap, 0.1f, Time.deltaTime);
   animator.SetFloat("Turn", turnAmount, 0.1f, Time.deltaTime);
   animator.speed = animationSpeedMultiplier;
  }

  void ApplyExtraTurnRotation()
  {
   // help the character turn faster (this is in addition to root rotation in the animation)
   float turnSpeed = Mathf.Lerp(stationaryTurnSpeed, movingTurnSpeed, forwardAmount);
   transform.Rotate(0, turnAmount * turnSpeed * Time.deltaTime, 0);
  }

  void OnAnimatorMove(){
   if (Time.deltaTime > 0) {
    Vector3 velocity = (animator.deltaPosition * moveSpeedMultiplier) / Time.deltaTime;
    // 保持y軸的速度不變
    velocity.y = myRigidbody.velocity.y;
    myRigidbody.velocity = velocity;
   }
  }
 }
}

以下提供Server端原始碼:
SyncOtherPlayerHandler.cs:

using System.Collections.Generic;
using Photon.SocketServer;
using NoliahFantasyServer.Constant;

namespace NoliahFantasyServer.Handler
{
 public class SyncOtherPlayerHandler : BaseHandler
    {
        public SyncOtherPlayerHandler()
        {
            operationCode = OperationCode.SyncOtherPlayerPosition;
        }

        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, MyClientPeer myClientPeer)
        {
            List userAccountList = new List();
            foreach(MyClientPeer otherPeer in NoliahFantasyServer.peerList){
                if(OtherPeerHasUserAccount(otherPeer, myClientPeer)){
                    // 紀錄其他玩家的帳號清單
                    userAccountList.Add(otherPeer.userAccount);
                    NoliahFantasyServer.logger.Info("其他線上玩家的帳號:" + otherPeer.userAccount);
                }
            }

            // 設定回傳Data
            Dictionary responseData = new Dictionary();
            responseData.Add((byte)ParameterCode.AccountList, userAccountList.ToArray());
            OperationResponse operationResponse = new OperationResponse(
                (byte)OperationCode.SyncOtherPlayerPosition, responseData);
            // 發送Response
            myClientPeer.SendOperationResponse(operationResponse, sendParameters);

            // 通知其他Peer有玩家登入場景
            foreach (MyClientPeer otherPeer in NoliahFantasyServer.peerList)
            {
                NoliahFantasyServer.logger.Info("發送Event給其他線上玩家的帳號:" + otherPeer.userAccount);
                if (OtherPeerHasUserAccount(otherPeer, myClientPeer))
                {
                    Dictionary eventDataDict = new Dictionary();
                    eventDataDict.Add((byte)ParameterCode.Acccount, myClientPeer.userAccount);

                    EventData eventData = new EventData((byte)EventCode.NewPlayer);
                    eventData.Parameters = eventDataDict;
                    otherPeer.SendEvent(eventData, sendParameters);
                }
            }
        }

        bool OtherPeerHasUserAccount(MyClientPeer otherPeer, MyClientPeer me){
            return string.IsNullOrEmpty(otherPeer.userAccount) == false && otherPeer != me;
        }
    }
}


SyncPositionHandler.cs:

using System;
using System.Collections.Generic;
using Photon.SocketServer;
using NoliahFantasyServer.Constant;

namespace NoliahFantasyServer.Handler
{
    public class SyncPositionHandler : BaseHandler
    {
        public SyncPositionHandler()
        {
            operationCode = OperationCode.SyncPosition;
        }

        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, MyClientPeer myClientPeer)
        {
            object xPosition;
            operationRequest.Parameters.TryGetValue((byte)ParameterCode.x, out xPosition);
            object yPosition;
            operationRequest.Parameters.TryGetValue((byte)ParameterCode.y, out yPosition);
            object zPosition;
            operationRequest.Parameters.TryGetValue((byte)ParameterCode.z, out zPosition);

            myClientPeer.xPosition = (float)xPosition;
            myClientPeer.yPosition = (float)yPosition;
            myClientPeer.zPosition = (float)zPosition;

        }
    }
}


LoginHandler.cs:

using Photon.SocketServer;
using NoliahFantasyServer.Constant;
using NoliahFantasyServer.Manager;

namespace NoliahFantasyServer.Handler
{
    public class LoginHandler : BaseHandler
    {
        public LoginHandler(){
            operationCode = OperationCode.Login;
        }

        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, MyClientPeer myClientPeer)
        {
            // 從OperationRequest取得帳號密碼資訊
            object account;
            operationRequest.Parameters.TryGetValue((byte)ParameterCode.Acccount, out account);
            object password;
            operationRequest.Parameters.TryGetValue((byte)ParameterCode.Password, out password);

            // 使用UserManager向資料庫驗證使用者的帳號密碼
            UserManager userManager = new UserManager();
            bool isVerify = userManager.VerifyUser(account as string, password as string);

            // 使用short類型的ReturnCode簡單回傳結果
            OperationResponse operationResponse = new OperationResponse((byte)OperationCode.Login);
            if(isVerify){
                operationResponse.ReturnCode = (short)ReturnCode.Success;
            }else{
                operationResponse.ReturnCode = (short)ReturnCode.Failed;
            }
            // 發送Response
            myClientPeer.SendOperationResponse(operationResponse, sendParameters);

            // 紀錄User Account
            NoliahFantasyServer.logger.Info("玩家登入:" + account.ToString());
            myClientPeer.userAccount = account.ToString();
        }
    }
}


NoliahFantasyServer.cs:

using System.IO;
using System.Collections.Generic;
using Photon.SocketServer;
using ExitGames.Logging;
using log4net.Config;
using NoliahFantasyServer.Constant;
using NoliahFantasyServer.Handler;

namespace NoliahFantasyServer
{
    public class NoliahFantasyServer : ApplicationBase
    {

        public static readonly ILogger logger = LogManager.GetCurrentClassLogger();
        public static Dictionary handlerDict =
            new Dictionary();
        // 透過PeerList,可向任何一個客戶端發送數據
        public static List peerList = new List();

        // 當Client端發出Request的時候
        protected override PeerBase CreatePeer(InitRequest initRequest)
        {
            MyClientPeer peer = new MyClientPeer(initRequest);
            peerList.Add(peer);
            return peer;
        }

        // Server端啟動的時候初始化
        protected override void Setup()
        {
            InitLogger();
            InitHandler();
        }

        // Server端關閉的時候
        protected override void TearDown()
        {
        }

        void InitLogger()
        {
            // 日誌初始化
            log4net.GlobalContext.Properties["Photon:ApplicationLogPath"] = 
                Path.Combine(this.ApplicationRootPath, "bin_Win64", "log");
            FileInfo loggerConfig = new FileInfo(Path.Combine(this.BinaryPath, "log4net.config"));
            if (loggerConfig.Exists)
            {
                // 設置使用log4net的Log功能
                LogManager.SetLoggerFactory(ExitGames.Logging.Log4Net.Log4NetLoggerFactory.Instance);
                // 讓log4net讀取config
                XmlConfigurator.ConfigureAndWatch(loggerConfig);
            }
            logger.Info("Setup Log4Net Compeleted!");
        }

        void InitHandler(){
            LoginHandler loginHandler = new LoginHandler();
            handlerDict.Add(loginHandler.operationCode, loginHandler);
            SignupHandler signupHandler = new SignupHandler();
            handlerDict.Add(signupHandler.operationCode, signupHandler);
            SyncPositionHandler syncPositionHandler = new SyncPositionHandler();
            handlerDict.Add(syncPositionHandler.operationCode, syncPositionHandler);
            SyncOtherPlayerHandler syncOtherPlayerHandler = new SyncOtherPlayerHandler();
            handlerDict.Add(syncOtherPlayerHandler.operationCode, syncOtherPlayerHandler);
        }

    }
}


最後來測試遊戲看看吧,這次就成功啦!

可是發現了一個很好笑的Bug,移動自己的時候,另一個複製的玩家也會跟著移動。這個Bug會於下一章介紹位置資訊同步的時候一併解決。

留言