PhotonServer服务器利用NHibernate操作数据库与客户端交互(登录、注册、多人位置同步)

1. 服务器端

1.1 服务器端工程准备

        此次项目内容是对上两次的整和,所以前两篇博文是单个功能的基础,分别是①NHibernate和MySQL交互,②PhotonServer的使用。这次项目也是在这两个基础之上进行的,很多直接拷贝过来进行修改;数据库还是用mygamedb,所以对PhotonServer目录下deploy\bin_Win64中PhotonControl.exe.config的配置文件不会更改,只用到了其中的Users表,Users表包含userID和username、password。

        NHibernate和MySQL交互工程如下:


        其中,

①hibernate.cfg.xml为NHibernate连接数据库的配置文件,名称不可更改,且属性为“始终复制到输出路径”;

②User.hbm.xml为数据库中Users表与定义的User类对应的配置文件,属性为“不复制,嵌入的资源”;

③User为定义的与Users表对应的字段;NHibernateHelper是对NHibernate创建会话的一个封装; ④IUserManager定义了操作Users表的方法(例如:对Users表添加、删除、修改、通过UserID查找、通过username查找、账号匹配等方法);

⑤而UserManager就是继承自IUserManager用NHibernate的会话实现这些方法。

        将所提到的类及配置文件(如有目录就包扩目录)全部复制到MyGameServer工程下,如图,本来的属性设置不变,会将程序集名和命名空间设置为MyGameServer的;!!!!!!!!重要:要将NHibernate和MySQL以及Log4Net所需引用进来的,Common是Common建后引用的。



下面介绍详细复制来文件的更改:

1.1.1 IUserManager.cs

        将命名空间修改为MyGameServer,应用所在的User类也改为using MyGameServer.Model。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MyGameServer.Model;

namespace MyGameServer.Manager
{
    interface IUserManager
    {
        void Add(User user);
        void Update(User user);
        void Remove(User user);
        User GetById(int id);
        User GetByUsername(string username);
        ICollection<User> GetAllUsers();

        bool VerifyUser(string username, string password);//验证用户名和密码
    }
}

1.1.2 UserManager.cs

        本来的引用命名空间变为MyGameServer.Model。

using System;
using System.Collections.Generic;
using MyGameServer.Manager;
using NHibernate;
using NHibernate.Criterion;
using MyGameServer.Model;

namespace MyGameServer.Manager
{
    class UserManager :IUserManager
    {
        public void Add(Model.User user)
        {
            /*第一种
            ISession session = NHibernateHelper.OpenSession();
            session.Save(user);
            session.Close();
            */
            using (ISession session = NHibernateHelper.OpenSession())
            {
                using (ITransaction transaction = session.BeginTransaction())
                {
                    session.Save(user);
                    transaction.Commit();
                }
            }
        }

        /// <summary>
        /// 得到表中所有内容
        /// </summary>
        /// <returns></returns>
        public ICollection<User> GetAllUsers()
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                IList<User> users = session.CreateCriteria(typeof(User)).List<User>();
                return users;
            }
        }

        public User GetById(int id)
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                //事务(事务中的一系列事件,只要有一个不成功,之前成功的也会回滚,即插入成功的又被删除,修改成功的又恢复.....)
                //    transaction = session.BeginTransaction();//开启事务
                using (ITransaction transaction = session.BeginTransaction())
                {
                    User user= session.Get<User>(id);
                    transaction.Commit();

                    return user;
                }
            }
        }

        public User GetByUsername(string username)
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                /*
                ICriteria criteria= session.CreateCriteria(typeof(User));
                criteria.Add(Restrictions.Eq("Username", username));//添加一个查询条件,第一个参数表示对哪个属性(字段)做限制,第二个表示值为多少

                User user = criteria.UniqueResult<User>();
                */
                User user = session.CreateCriteria(typeof(User)).Add(Restrictions.Eq("Username", username)).UniqueResult<User>();
                return user;
                
            }
        }

        /// <summary>
        /// NHibernate删除时根据主键更新,所以传来的对象user中得有主键
        /// </summary>
        /// <param name="user"></param>
        public void Remove(User user)
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                using (ITransaction transaction = session.BeginTransaction())
                {
                    session.Delete(user);
                    transaction.Commit();
                }
            }
        }

        /// <summary>
        /// NHibernate更新时根据主键更新,所以传来的对象user中得有主键
        /// </summary>
        /// <param name="user"></param>
        public void Update(User user)
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                using (ITransaction transaction = session.BeginTransaction())
                {
                    session.Update(user);
                    transaction.Commit();
                }
            }
        }

        public bool VerifyUser(string username, string password)
        {
            using (ISession session = NHibernateHelper.OpenSession())
            {
                User user = session
                    .CreateCriteria(typeof(User))
                    .Add(Restrictions.Eq("Username", username))
                    .Add(Restrictions.Eq("Password", password))
                    .UniqueResult<User>();
                if (user == null) return false;
                return true;
            }
        }
    }
}

1.1.3 User.hmb.xml

        只需将程序集名和User类所在位置更改。

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"
                   assembly="MyGameServer"  
                   namespace="MyGameServer.Model"> <!--assembly表示程序集,namespace所在文件夹Model-->

  <class name="User" table="users"><!--name为类名,table为表名-->
    <id name="ID" column="id" type="Int32"><!--id name对应为类中变量名,type为nhibernate的类型-->
    <generator class="native"></generator>
    </id><!--主键配置完成-->
    <property name="Username" column="username" type="String"></property>
    <property name="Password" column="password" type="String"></property>
    <property name="Registerdate" column="registerdate" type="Date"></property>
  </class>

</hibernate-mapping>

1.1.4 User.cs

        更改命名空间为MyGameServer。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MyGameServer.Model
{
    public class User
    {
        public virtual int ID { get; set; }
        public virtual string Username { get; set; }
        public virtual string Password { get; set; }
        public virtual DateTime Registerdate { get; set; }

    }
}

1.1.5 hibernate.cfg.xml

        连接数据库的配置文件不需要更改。

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-configuration xmlns="urn:nhibernate-configuration-2.2">
  <session-factory>
    <property name="connection.provider">NHibernate.Connection.DriverConnectionProvider</property>
    <property name="dialect">NHibernate.Dialect.MySQL5Dialect</property><!--版本-->
    <property name="connection.driver_class">NHibernate.Driver.MySqlDataDriver</property><!--使用什么数据库-->
    <property name="connection.connection_string">Server=localhost;Database=mygamedb;User ID=root;Password=root;</property>
    <property name="show_sql">true</property>
  </session-factory>
</hibernate-configuration>

1.1.6 NHibernateHelper.cs

        也只需要更改命名空间。

using NHibernate;
using NHibernate.Cfg;

namespace MyGameServer
{
    class NHibernateHelper
    {
        private static ISessionFactory _sessionFactory;

        private static ISessionFactory SessionFactory
        {
            get
            {
                if(_sessionFactory==null)
                {
                    var configuration = new Configuration();
                    configuration.Configure();//解析hibernate.cfg.xml
                    configuration.AddAssembly("MyGameServer");//解析映射文件User.hbm.xml

                    _sessionFactory = configuration.BuildSessionFactory();
                }
                return _sessionFactory;
            }
        }

        public static ISession OpenSession()
        {
            return SessionFactory.OpenSession();//打开一个跟数据库的会话
        }
    }
}

1.2 服务器端和客户端公共所需

        服务器端和客户端都所需要的内容定义在此工程中,例如将客户端发送来的请求类型用枚举类型进行区分,将服务器发送的事件类型用枚举类型区分,将服务器发送给客户端的参数进行枚举类型区分,将返回给客户端的单个值用枚举类型区分等等。下面详细介绍。

1.2.1 工程创建

        因为要在客户端和服务器都需要用,所以将工程创建为类库,同服务器端工程一样,如下图,最后生成dll文件在服务器端和客户端(Unity)进行使用,因为unity工程所使用的dll文件.Net版本不能超过3.5,所以将Common工程属性的.NET版本设置为3.5。



最终所有内容如下:


1.2.2 Common工程内容

①Tools/DictTool.cs

工具类,DictTools是为了通过传入的任何类型的字典和任何类型的key都可以获取各种类型value的一个工具类。

using System;
using System.Collections.Generic;


namespace Common.Tools
{
    public class DictTool
    {
        public static T2 GetValue<T1,T2>(Dictionary<T1,T2> dict,T1 key)
        {
            T2 value;
            bool isSuccess = dict.TryGetValue(key, out value);
            if(isSuccess)
            {
                return value;
            }
            else
            {
                return default(T2);
            }
        }
    }
}

② EventCode.cs

定义的服务器发送给客户端事件的枚举类型。

namespace Common
{
    public enum EventCode:byte//区分服务器向客户端发送事件类型
    {
        NewPlayer,//创建其他客户端player的事件
        SyncPosition//同步位置的事件
    }
}

③ OperationCode.cs

        定义的客户端发来的请求的枚举类型。

namespace Common
{
    public enum OperationCode:byte//区分客户端发来请求的类型
    {
        Login,//客户端发来登录的请求
        Register,//客户端发来注册的请求
        Default,//默认请求
        SyncPosition,//客户端发来自身位置的请求
        SyncPlayer//同步其他玩家信息的请求
    }
}

④ ParameterCode.cs

定义了服务器发送给客户端参数的枚举类型。

namespace Common
{
    public enum ParameterCode:byte//区分传送数据的时候,参数的类型
    {
        Username,
        Password,
        Position,
        X,Y,Z,//玩家的坐标xyz
        UsernameList,//所有的在线客户端用户名
        PlayerDataList//所有的在线客户端用户名以及坐标位置
    }
}

⑤ ReturnCode.cs

定义了服务器发送给客户端返回的参数(单个确认是否成功)枚举类型。

namespace Common
{
    public enum ReturnCode:short
    {
        Success,
        Failed
    }
}

⑥ PlayerData.cs

定义了服务器发送给所有客户端的其他客户端的名字和位置信息,注意:非枚举。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

/// <summary>
/// 服务器传送给所有在线的客户端其他客户端的名字和位置信息
/// 封装到此类中,后续再序列化传送出去
/// </summary>
namespace Common
{
    [Serializable]
    public class PlayerData
    {
        public Vector3Data Pos { get; set; }
        public string Username { get; set; }
    }
}

⑦ Vector3Data.cs

服务器端没有Vector3类,自定义的此类。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Common
{
    /// <summary>
    /// 客户端将自己的位置信息发送给服务器端,服务器端没有Vector3类,所以自己创建一个
    /// </summary>
    [Serializable]
    public class Vector3Data
    {
        public float x { get; set; }
        public float y { get; set; }
        public float z { get; set; }
    }
}

1.3 服务器端设计

            服务器端需要用到Common工程中内容,所以需要引入Common,方法:右键引用,选择添加引用后选择项目,勾选Common。然后每次客户端生成后在同一个bin目录会有Common的dll文件。


1.3.1 客户端发来的请求处理(Handler文件夹下)

1.3.1.1 请求处理的基类(BaseHandler.cs)

        定义一个基类,之后新增的请求处理都继承此类;包含了客户端发来的请求枚举类型,和响应请求方法OnOperationRequest,参数中operationRequest为发来的请求,sendParameters为响应回去的参数,peer用于区分哪个客户端。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Common;
using Photon.SocketServer;

namespace MyGameServer.Handler
{
    public abstract class BaseHandler
    {
        public OperationCode OpCode;

        //参数中operationRequest为发来的请求,sendParameters为响应回去的参数,peer用于区分哪个客户端
        public abstract void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters,ClientPeer peer);
    }
}


1.3.1.2 默认请求处理(DefaultHandler.cs)

        当传来的请求服务器端没有对应的处理时,调用此默认请求进行处理,但此时只设置了请求的类型。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Photon.SocketServer;

namespace MyGameServer.Handler
{
    class DefaultHandler : BaseHandler
    {
        public DefaultHandler()
        {
            OpCode = Common.OperationCode.Default;
        }
        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, ClientPeer peer)
        {
            
        }
    }
}

1.3.1.3 登录请求的响应(LoginHandler.cs)

        直接在构造方法中将请求的类型设置为Login。再在OnOperationRequest中响应客户端的请求,因为只是回应是否登录成功,所以用响应中的ReturnCode进行返回,ReturnCode为short类型。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Common;
using Photon.SocketServer;
using Common.Tools;
using MyGameServer.Manager;

namespace MyGameServer.Handler
{
    class LoginHandler : BaseHandler
    {
        public LoginHandler()
        {
            OpCode = OperationCode.Login;//将自身的请求设置
        }
        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, ClientPeer peer)
        {
            //从客户端传来的请求中用工具类得到用户名和密码
            string username = DictTool.GetValue<byte,object>(operationRequest.Parameters,(byte)ParameterCode.Username) as string;
            string password = DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.Password) as string;

            UserManager userManager = new UserManager();//UserManager中用NHibernate对数据库中Users表进行操作
            bool iSsuccess= userManager.VerifyUser(username, password);//验证账户在数据库中是否存在

            OperationResponse operationResponse = new OperationResponse(operationRequest.OperationCode);//创建回应
            if(iSsuccess)//账号密码在数据库中匹配成功
            {
                operationResponse.ReturnCode=(short)Common.ReturnCode.Success;//将传给客户端的参数设置为定义的枚举类型Success

                peer.username = username;
            }
            else
            {
                operationResponse.ReturnCode = (short)Common.ReturnCode.Failed;//将传给客户端的参数设置为定义的枚举类型Failed
            }

            peer.SendOperationResponse(operationResponse,sendParameters);//发送响应
        }
    }
}

1.3.1.4 注册请求的响应(RegisterHandler.cs)

        直接在构造方法中将请求的类型设置为Register。再在OnOperationRequest中响应客户端的请求,因为只是回应是否注册成功,所以用响应中的ReturnCode进行返回,ReturnCode为short类型。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Common;
using Common.Tools;
using MyGameServer.Manager;
using Photon.SocketServer;
using MyGameServer.Model;

namespace MyGameServer.Handler
{
    class RegisterHandler : BaseHandler
    {
        public RegisterHandler()
        {
            OpCode = OperationCode.Register;
        }
        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, ClientPeer peer)
        {
            //从客户端传来的请求中用工具类得到用户名和密码
            string username = DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.Username) as string;
            string password = DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.Password) as string;

            UserManager userManager = new UserManager();//UserManager中用NHibernate对数据库中Users表进行操作
            User user = userManager.GetByUsername(username);//通过username得到User

            OperationResponse response = new OperationResponse(operationRequest.OperationCode);
            if(user==null)//查看数据库中是否已经有要添加的用户
            {
                user = new User() { Username = username, Password = password };
                userManager.Add(user);//符合要求,向数据库中新增账户和密码
                response.ReturnCode = (short)ReturnCode.Success;//设置传递给客户端的枚举类型
            }
            else
            {
                response.ReturnCode = (short)ReturnCode.Failed;
            }
            peer.SendOperationResponse(response, sendParameters);
        }
    }
}

1.3.1.5 发送同步位置的事件(SyncPlayerHandler.cs)

        向所有在线客户端发送同步位置的事件。主动向客户端发送,所以用事件发送。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Common;
using Photon.SocketServer;
using System.Xml.Serialization;
using System.IO;

namespace MyGameServer.Handler
{
    class SyncPlayerHandler : BaseHandler
    {
        public SyncPlayerHandler()
        {
            OpCode= OperationCode.SyncPlayer;
        }
        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, ClientPeer peer)
        {
            //取得所有已经登录(在线)的用户名
            List<string> usernameList = new List<string>();//储存所有已经登录(在线)的用户名
            foreach (ClientPeer tempPeer in MyGameServer.Instance.peerList)//遍历所有已经登录的用户
            {
                //除开没有登录的和自身不保存到usernameList中
                if(string.IsNullOrEmpty(tempPeer.username)==false&&tempPeer!=peer)
                {
                    usernameList.Add(tempPeer.username);
                }
            }

            //**************将usernameList序列化
            StringWriter sw = new StringWriter();
            XmlSerializer serializer = new XmlSerializer(typeof(List<string>));
            serializer.Serialize(sw, usernameList);
            sw.Close();
            string usernameListString = sw.ToString();



            Dictionary<byte, object> data = new Dictionary<byte, object>();
            data.Add((byte)ParameterCode.UsernameList, usernameListString);
            OperationResponse response = new OperationResponse(operationRequest.OperationCode);

            response.Parameters = data;

            peer.SendOperationResponse(response, sendParameters);


            //告诉其他客户端有新的客户端加入
            //用事件发送
            foreach (ClientPeer tempPeer in MyGameServer.Instance.peerList)
            {
                if (string.IsNullOrEmpty(tempPeer.username) == false && tempPeer != peer)
                {
                    EventData ed = new EventData((byte)EventCode.NewPlayer);

                    Dictionary<byte, object> dataToAll = new Dictionary<byte, object>();//储存向所有客户端发送的数据
                    dataToAll.Add((byte)ParameterCode.Username, peer.username);

                    ed.Parameters = dataToAll;//将要发送事件的参数设为要给所有客户端传送的数据

                    tempPeer.SendEvent(ed, sendParameters);//发送事件
                }
            }
        }
    }
}

1.3.1.6 客户端将自身位置发送到服务器端,服务器端的处理(SyncPositionHandler.cs)

        服务器端收到客户端发来的自身位置请求后,服务器端先得到三个坐标,然后储存到对应客户端的xyz中。

using System;
using System.Collections.Generic;
using System.Linq;
using Common.Tools;
using Common;
using Photon.SocketServer;

namespace MyGameServer.Handler
{
    class SyncPositionHandler : BaseHandler
    {
        public SyncPositionHandler()
        {
            OpCode = OperationCode.SyncPosition;
        }
        public override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters, ClientPeer peer)
        {
            Vector3Data pos=(Vector3Data)DictTool.GetValue<byte,object>(operationRequest.Parameters,(byte)ParameterCode.Position);

            float x=(float)DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.X);
            float y = (float)DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.Y);
            float z = (float)DictTool.GetValue<byte, object>(operationRequest.Parameters, (byte)ParameterCode.Z);

            peer.x = x;peer.y = y;peer.z = z;

        }
    }
}

1.3.2 服务器向所有客户端发送其他所有客户端位置的线程(Threads文件夹下)

        在Threads文件夹下定义了SyncPositionThread类,用来创建一个线程,在线程中通过发送事件将客户端的位置发送到所有在线以登录的客户端中。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Common;
using System.Xml.Serialization;
using System.IO;
using Photon.SocketServer;

namespace MyGameServer.Threads
{
    public class SyncPositionThread
    {
        private Thread t;
        /// <summary>
        /// 开启线程
        /// </summary>
        public void Run()
        {
            MyGameServer.log.Info("线程运行中");
            t = new Thread(UpdatePosition);
            t.IsBackground = true;
            t.Start();

        }
        /// <summary>
        /// 结束线程
        /// </summary>
        public void StopThread()
        {
            t.Abort();
        }
        private void UpdatePosition()
        {
            Thread.Sleep(2000);//停止2秒

            while(true)
            {
                Thread.Sleep(200);
                //进行位置同步
                //收集当前所有的客户端发送其他客户端位置
                SendPosition();
            }
        }
        private void SendPosition()
        {
            List<PlayerData> playerDataList = new List<PlayerData>();
            foreach(ClientPeer peer in MyGameServer.Instance.peerList)//遍历所有客户端
            {
                if(string.IsNullOrEmpty(peer.username)==false) //客户端在线
                {
                    PlayerData playerData = new PlayerData();
                    playerData.Username = peer.username;//将此在线客户端的名字保存
                    playerData.Pos = new Vector3Data() { x = peer.x, y = peer.y, z = peer.z };//将此在线客户端的位置信息保存

                    playerDataList.Add(playerData);
                }
            }
            ////序列化所有player的名字和位置信息(PlayerData)
            StringWriter sw = new StringWriter();
            XmlSerializer serializer = new XmlSerializer(typeof(List<PlayerData>));
            serializer.Serialize(sw, playerDataList);//序列化到sw中
            sw.Close();

            string playerDataListString = sw.ToString();
            Dictionary<byte, object> data = new Dictionary<byte, object>();
            data.Add((byte)ParameterCode.PlayerDataList, playerDataListString);


            //向所有在线客户端发送其他客户端的名字和位置信息(PlayerData)
            foreach(ClientPeer peer in MyGameServer.Instance.peerList)
            {
                if(string.IsNullOrEmpty(peer.username)==false)
                {
                    EventData ed = new EventData((byte)EventCode.SyncPosition);
                    ed.Parameters = data;
                    peer.SendEvent(ed, new SendParameters());
                }
            }
        }

    }
}

1.3. 服务器接收请求并处理(ClientPeer.cs)

        继承自Photon.SocketServer.ClientPeer,当客户端连接到服务器、断开于服务器和发来请求时相应的处理,并定义了用来储存每个客户端peer的坐标信息,和名字。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Photon.SocketServer;
using PhotonHostRuntimeInterfaces;
using Common.Tools;
using Common;
using MyGameServer.Handler;

namespace MyGameServer
{
    public class ClientPeer : Photon.SocketServer.ClientPeer
    {
        public float x, y, z;//储存客户端中Player坐标
        public string username;//储存客户端登录的用户名

        public ClientPeer(InitRequest initRequest) : base(initRequest)
        {

        }

        /// <summary>
        /// 处理断开连接的处理工作
        /// </summary>
        /// <param name="reasonCode"></param>
        /// <param name="reasonDetail"></param>
        protected override void OnDisconnect(DisconnectReason reasonCode, string reasonDetail)
        {
            MyGameServer.Instance.peerList.Remove(this);//每一个客户端断开连接后,将此客户端从peerList中移除
        }

        /// <summary>
        /// 处理客户端发来的请求
        /// </summary>
        /// <param name="operationRequest"></param>
        /// <param name="sendParameters"></param>
        protected override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters)
        {
            //通过请求的OperationCode找到对应的处理handler
            BaseHandler handler = DictTool.GetValue<OperationCode, BaseHandler>(MyGameServer.Instance.handlerDict, (OperationCode)operationRequest.OperationCode);
            if(handler!=null)
            {
                handler.OnOperationRequest(operationRequest, sendParameters, this);//用对应的handler进行处理
            }
            else
            {
                BaseHandler defaultHandler = DictTool.GetValue<OperationCode, BaseHandler>(MyGameServer.Instance.handlerDict, OperationCode.Default);
                defaultHandler.OnOperationRequest(operationRequest, sendParameters, this);
            }
        }
    }
}

1.4 服务器主类(服务器入口)

        定义的MyGameServer继承了ApplicationBase,为服务器的主类,即入口类,所有服务器端的主类都必须继承自ApplicationBase,处理服务器的初始化,客户端连接、服务器关闭,并自己定义了handlerDict字典,储存所有的请求,方便当有请求发来时服务器可通过operationRequest.OperationCode来区分是何种请求;定义了peerList集合,每个客户端连接服务器后都被添加进来,用于后续向他们发送数据。发送同步位置的线程也是在服务器启动后开启。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Common;
using ExitGames.Logging;
using ExitGames.Logging.Log4Net;
using log4net.Config;
using MyGameServer.Handler;
using MyGameServer.Manager;
using Photon.SocketServer;
using MyGameServer.Threads;

namespace MyGameServer
{
    //所有的server端,主类都要继承自ApplicationBase
    public class MyGameServer : ApplicationBase
    {
        public static readonly ILogger log = LogManager.GetCurrentClassLogger();//日志

        public static MyGameServer Instance
        {
            get;
            private set;
        }

        public Dictionary<OperationCode, BaseHandler> handlerDict=new Dictionary<OperationCode, BaseHandler>();//储存客户端传来的请求,方便当有请求发来时服务器可通过operationRequest.OperationCode来区分是何种请求

        public List<ClientPeer> peerList = new List<ClientPeer>();//存放所有的客户端,通过这个集合可以访问到所有客户端的peer,用于向所有客户端发送数据(其他玩家的位置)

        private SyncPositionThread syncPositionThread = new SyncPositionThread();

        //当一个客户端请求连接时调用
        //使用peerbase表示和一个客户端的连接
        //Photon会自动将所有的客户端管理起来
        protected override PeerBase CreatePeer(InitRequest initRequest)
        {
            log.Info("一个客户端连接进来了.................");
            ClientPeer peer=new ClientPeer(initRequest);
            peerList.Add(peer);

            return peer;
        }

        /// <summary>
        /// server启动后就调用,初始化
        /// </summary>
        protected override void Setup()
        {
            Instance = this;

            //日志的初始化
            log4net.GlobalContext.Properties["Photon:ApplicationLogPath"]=Path.Combine(
                                                                                        Path.Combine( this.ApplicationRootPath,"bin_Win64"),"log");//设置日志输出路径
            FileInfo configFileInfo = new FileInfo(Path.Combine(this.BinaryPath, "log4net.config")); //BinaryPath为工程的bin目录
            if (configFileInfo.Exists)
            {
                LogManager.SetLoggerFactory(Log4NetLoggerFactory.Instance);//让photon知道使用哪个日志插件
                XmlConfigurator.ConfigureAndWatch(configFileInfo);//让log4net这个插件读取配置文件
            }

            log.Info("Setup Completed!");

            InitHandler();
            syncPositionThread.Run();//开启同步客户端player位置的线程
        }

        /// <summary>
        /// server端关闭时
        /// </summary>
        protected override void TearDown()
        {
            syncPositionThread.StopThread();//关闭线程

            log.Info("服务器应用关闭了");
        }

        /// <summary>
        /// 初始化所有的Handler,每新增一种请求就有一种对应处理请求的Handler,
        /// 并且需要添加到handlerDict中
        /// </summary>
        void InitHandler()
        {
            LoginHandler loginHandler = new LoginHandler();
            DefaultHandler defaultHandler = new DefaultHandler();
            RegisterHandler registerHandler = new RegisterHandler();
            SyncPositionHandler syncPositionHandler = new SyncPositionHandler();
            SyncPlayerHandler syncPlayerHandler = new SyncPlayerHandler();

            handlerDict.Add(loginHandler.OpCode, loginHandler);
            handlerDict.Add(defaultHandler.OpCode, defaultHandler);
            handlerDict.Add(registerHandler.OpCode, registerHandler);
            handlerDict.Add(syncPositionHandler.OpCode, syncPositionHandler);
            handlerDict.Add(syncPlayerHandler.OpCode, syncPlayerHandler);
        }
    }
}


保存好后,右键点击工程,生成,就会也将Common.dll也生成库文件了。

2. 客户端

        新建unity工程,再建文件夹Plugins将Photonserver的\lib文件夹的Photon3Unity3D放于unity工程的Plugins文件夹下,并将Common.dll文件也放于此文件夹下。

2.1 场景的准备

2.2.1 登录场景

        新建场景,MainGame,为登录场景,有输出用户名和密码的InputFiled,和一个确定按钮;一共有两个UI面板,一个登录一个注册。

        如图:


2.2.2 游戏场景

        新建场景GameScene,包含了一个主角player和地面,还有一个用于控制player的空游戏物体。

如图:


2.2 登录注册

2.2.1 与服务器“交流”(PhotonEngine.cs)

        挂载在登录场景的PhotonEngine游戏物体上,需要继承IPhotonPeerListener,用单例并在场景中不会销毁,用于连接服务器,处理服务器的响应、事件等;并定义了储存所有请求的字典,和储存所有事件的字典,及操作这两个字典的方法。

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

public class PhotonEngine : MonoBehaviour,IPhotonPeerListener {

    public static PhotonEngine Instance;
    private static PhotonPeer peer;

    public static PhotonPeer Peer
    {
        get
        {
            return peer;
        }
    }

    private Dictionary<OperationCode, Request> RequestDict = new Dictionary<OperationCode, Request>();//储存所有的请求
    private Dictionary<EventCode, BaseEvent> EventDict = new Dictionary<EventCode, BaseEvent>();//储存所有的事件
         
    public static string username;//储存当前所登录的账户名
    void Awake()
    {
        if(Instance==null)
        {
            Instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        else if(Instance!=this)//若跳转到其他场景,删除多余的PhotonEngine
        {
            Destroy(this.gameObject);
            return;
        }
    }
    // Use this for initialization
    void Start () {
        //通过Listender接收服务器端的响应
        peer = new PhotonPeer(this, ConnectionProtocol.Udp);
        peer.Connect("127.0.0.1:5055", "MyGame1");//连接服务器,指定IP的端口号,第二个参数为应用名
	}

    // Update is called once per frame
    void Update()
    {
         peer.Service();//需要一直调用
    }

    /// <summary>
    /// 游戏停止或者关闭时,要断开连接
    /// </summary>
    void OnDestroy()
    {
        if(peer!=null&&peer.PeerState==PeerStateValue.Disconnected)
        {
            peer.Disconnect();
        }
    }

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

    /// <summary>
    /// 处理服务器发送的事件
    /// </summary>
    /// <param name="eventData"></param>
    public void OnEvent(EventData eventData)
    {
        EventCode code = (EventCode)eventData.Code;
        BaseEvent e = DictTool.GetValue<EventCode, BaseEvent>(EventDict, code);//通过EventCode的类型从字典中得到事件类
        e.OnEvent(eventData);
    }

    /// <summary>
    /// //接收到服务器发送回的响应
    /// </summary>
    /// <param name="operationResponse"></param>
    public void OnOperationResponse(OperationResponse operationResponse)
    {
        //接收到服务器发送回的响应
        OperationCode opCode = (OperationCode)operationResponse.OperationCode;
        Request request = null;

        bool temp = RequestDict.TryGetValue(opCode, out request);

        if(temp)
        {
            request.OnOperationResponse(operationResponse);
        }
        else
        {
            Debug.Log("没有找到对应的响应处理对象");
        }
    }

    public void OnStatusChanged(StatusCode statusCode)
    {
        Debug.Log(statusCode);
    }

    /// <summary>
    /// 向RequestDict中添加请求
    /// </summary>
    public void AddRequest(Request request)
    {
        RequestDict.Add(request.OpCode, request);
    }
    /// <summary>
    /// 移除RequestDict中请求
    /// </summary>
    public void RemoveRequest(Request request)
    {
        RequestDict.Remove(request.OpCode);
    }

    /// <summary>
    /// 向事件字典eventDict中添加事件
    /// </summary>
    /// <param name="e"></param>
    public void AddEvent(BaseEvent e)
    {
        EventDict.Add(e.EventCode, e);
    }

    /// <summary>
    /// 移除字典eventDict中的事件
    /// </summary>
    /// <param name="e"></param>
    public void RemoveEvent(BaseEvent e)
    {
        EventDict.Remove(e.EventCode);
    }
}

2.2.2 进行请求

2.2.2.1 请求的基类(Request.cs)

在Scripts/Request下新建脚本Request,需要继承MonoBehaviour,因为他的子类需要挂载到场景中;这是所有请求的基类,定义了请求的枚举类型,并在Start()方法中将自身(也就是Request或者其子类)添加到PhotonEngine所定义的请求字典RequestDict中,销毁时从RequestDict中移除;定义了发送请求的方法和接收响应的方法。

using Common;
using ExitGames.Client.Photon;
using UnityEngine;

public abstract class Request:MonoBehaviour {
    public OperationCode OpCode;

    public abstract  void DefaultRequest();
    public abstract void OnOperationResponse(OperationResponse operationResponse);

    public virtual void Start()
    {
        PhotonEngine.Instance.AddRequest(this);
    }
    public void OnDestroy()
    {
        PhotonEngine.Instance.RemoveRequest(this);
    }
}

2.2.2.2 登录请求(LoginRequest.cs)

        处理发送请求和服务器传来的响应,登录验证成功后交给loginPanel进行UI的操作。

挂载到LoginPanel上,并设置opCode为login

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

public class LoginRequest :Request {
    [HideInInspector]
    public string Username;
    [HideInInspector]
    public string Password;


    private LoginPanel loginPanel;//得到服务器的请求响应后,UI的处理(即跳转场景)传递给LoginPanel处理

    public override void Start()
    {
        base.Start();

        loginPanel = GetComponent<LoginPanel>();
    }

    /// <summary>
    /// //向服务器发送请求
    /// </summary>
    public override void DefaultRequest()
    {
        Dictionary<byte, object> data = new Dictionary<byte, object>();
        data.Add((byte)ParameterCode.Username, Username);
        data.Add((byte)ParameterCode.Password, Password);

        PhotonEngine.Peer.OpCustom((byte)OpCode, data, true);

    }

    /// <summary>
    /// 处理服务器传来的响应
    /// </summary>
    /// <param name="operationResponse"></param>
    public override void OnOperationResponse(OperationResponse operationResponse)
    {
        Debug.Log(operationResponse.ReturnCode);//0为成功,1为失败
        ReturnCode returnCode = (ReturnCode)operationResponse.ReturnCode;

        if(returnCode==ReturnCode.Success)
        {
            PhotonEngine.username = Username;//储存当前登录的用户名
        }
        loginPanel.OnLoginResponse(returnCode);
    }
}

2.2.2.3 注册请求(LoginRequest.cs)

        处理发送请求和服务器传来的响应,成功后交给RegisterPanel进行UI的操作。

挂载到RegisterPanel上,并将OpCode设置为register。

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

public class RegisterRequest : Request {
    [HideInInspector]
    public string Username;
    [HideInInspector]
    public string Password;

    private RegisterPanel registerPanel;//得到服务器的请求响应后,UI的处理(即跳转场景)传递给registerPanel处理

    public override void Start()
    {
        base.Start();

        registerPanel = GetComponent<RegisterPanel>();
    }

    /// <summary>
    /// //向服务器发送请求
    /// </summary>
    public override void DefaultRequest()
    {
        Dictionary<byte, object> data = new Dictionary<byte, object>();
        data.Add((byte)ParameterCode.Username, Username);
        data.Add((byte)ParameterCode.Password, Password);

        PhotonEngine.Peer.OpCustom((byte)OpCode, data, true);//向服务器发送请求
    }

    /// <summary>
    /// 处理服务器传来的响应
    /// </summary>
    /// <param name="operationResponse"></param>
    public override void OnOperationResponse(OperationResponse operationResponse)
    {
        ReturnCode returnCode = (ReturnCode)operationResponse.ReturnCode;

        registerPanel.OnRegisterResponse(returnCode);
    }
}

2.2.3 登录的启动(LoginPanel.cs)

        在Scripts/UI下新建脚本LoginPanel,挂载到LoginPanel物体上,处理UI的事件(包括按钮事件,需手动赋值),并进行各自请求的发送。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Common;
using UnityEngine.SceneManagement;

public class LoginPanel : MonoBehaviour {

    public GameObject registerPanel;

    public InputField usernameIF;
    public InputField passwordIF;
    public Text hitMessage;//提示信息

    private LoginRequest loginRequest;

    private void Start()
    {
        loginRequest = GetComponent<LoginRequest>();

    }
    /// <summary>
    /// 登录按钮点击
    /// </summary>
    public void OnLoginButton()
    {
        hitMessage.text = "";//每次点击登陆后将上一次提示信息清空

        loginRequest.Username = usernameIF.text;
        loginRequest.Password = passwordIF.text;

        loginRequest.DefaultRequest();
    }
    /// <summary>
    /// 注册按钮点击
    /// </summary>
    public void OnRegisterButton()
    {
        registerPanel.SetActive(true);
        gameObject.SetActive(false);
    }

    /// <summary>
    /// 处理服务器传来的响应
    /// </summary>
    /// <param name="returnCode"></param>
    public void OnLoginResponse(ReturnCode returnCode)
    {
        if(returnCode==ReturnCode.Success)
        {
            //跳转到下一个场景
            SceneManager.LoadScene("GameScene");
        }
        else
        {
            hitMessage.text = "账号或密码错误!";
        }
    }
}

        设置如下:



2.2.4 注册的启动(registerPanel.cs)

在Scripts/UI下新建脚本RegisterPanel,挂载到RegisterPanel物体上,处理UI的事件(包括按钮事件,需手动赋值),并进行各自请求的发送。

using Common;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class RegisterPanel : MonoBehaviour {

    public GameObject loginPanel;

    public InputField usernameIF;
    public InputField passwordIF;
    public Text hitMessage;

    private RegisterRequest registerRequest;

    private void Start()
    {
        registerRequest = GetComponent<RegisterRequest>();

    }
    public void OnRegisterButton()
    {
        hitMessage.text = "";//每次点击登陆后将上一次提示信息清空

        registerRequest.Username = usernameIF.text;
        registerRequest.Password = passwordIF.text;

        registerRequest.DefaultRequest();
    }

    public void OnBackButton()
    {
        loginPanel.SetActive(true);
        gameObject.SetActive(false);
    }

    /// <summary>
    /// 处理服务器传来的响应
    /// </summary>
    /// <param name="returnCode"></param>
    public void OnRegisterResponse(ReturnCode returnCode)
    {
        if (returnCode == ReturnCode.Success)
        {
            hitMessage.text = "注册成功,返回登录!";
        }
        else
        {
            hitMessage.text = "账号或密码已存在,更改后重新注册!";
        }
    }
}

设置如下:



2.3 游戏中角色同步

2.3.1 自身位置上传请求(SyncPositionRequest.cs)

        在Stripts/Request下新建脚本SyncPositionRequest,需要挂载到PlayerController上;在面板上设置opCode为SyncPosition;将自身位置上传的服务器的请求。

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

/// <summary>
/// 向服务器发送位置请求
/// </summary>
public class SyncPositionRequest : Request {
    // Use this for initialization
    [HideInInspector]
    public Vector3 pos;

    // Update is called once per frame
    void Update()
    {

    }
    public override void DefaultRequest()
    {
        Dictionary<byte, object> data = new Dictionary<byte, object>();
        //data.Add((byte)ParameterCode.Position, new Vector3Data() { x=pos.x, y=pos.y, z=pos.z });//将位置信息通过自定义的Vector3发送给服务器端
        data.Add((byte)ParameterCode.X, pos.x);
        data.Add((byte)ParameterCode.Y, pos.y);
        data.Add((byte)ParameterCode.Z, pos.z);

        PhotonEngine.Peer.OpCustom((byte)OpCode, data, true);

    }

    public override void OnOperationResponse(OperationResponse operationResponse)
    {
        throw new System.NotImplementedException();
    }


}

2.3.2 同步其他客户端player位置请求

        在Stripts/Request下新建脚本SyncPlayerDataRequest,需要挂载到PlayerController上;在面板上设置opCode为SyncPlayer;将服务器的响应传来的参数反序列化出PlayerData。

using System.Collections;
using System.Collections.Generic;
using ExitGames.Client.Photon;
using UnityEngine;
using Common;
using Common.Tools;
using System.Xml.Serialization;
using System.IO;

/// <summary>
/// 同步其他客户端玩家位置信息的请求
/// 包括创建其他客户端
/// </summary>
public class SyncPlayerRequest : Request {
    private Player player;

    public override void Start()
    {
        base.Start();
        player = GetComponent<Player>();

    }
    public override void DefaultRequest()
    {
        PhotonEngine.Peer.OpCustom((byte)OpCode, null, true);
    }

    public override void OnOperationResponse(OperationResponse operationResponse)
    {
        string usernameListString=(string)DictTool.GetValue<byte, object>(operationResponse.Parameters, (byte)ParameterCode.UsernameList);

        //反序列化
        using (StringReader reader = new StringReader(usernameListString))
        {
            XmlSerializer serializer = new XmlSerializer(typeof(List<string>));
            List<string> usernameList=(List<string>)serializer.Deserialize(reader);

            player.OnSyncPlayerResponse(usernameList);

        }
    }
}

2.3.2 服务器发来的事件处理

2.3.2.1 事件基类(BaseEvent.cs)

        在Stripts/Event下新建脚本BaseEvent.cs,继承自MonoBehaviour,因为其子类需要挂载到场景中,定义了传来的事件类型的枚举类型,在Start()方法中处理添加本身到PhotonEngine的eventDict中,OnDestroy()中将自身移除。

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

/// <summary>
/// 处理收到的服务器的事件
/// </summary>
public abstract class BaseEvent : MonoBehaviour {

    public EventCode EventCode;

    public abstract void OnEvent(EventData eventData);

    public virtual void Start()
    {
        PhotonEngine.Instance.AddEvent(this);
    }
    public void OnDestroy()
    {
        PhotonEngine.Instance.RemoveEvent(this);
    }
}
2.3.2.2 实例化其他客户端事件(NewPlayerEvent.cs)

        在Stripts/Event下新建脚本NewPlayerEvent.cs,需要挂载到PlayerController,在面板上设置EventCode为NewPlayer;只需要得到名字,实例化的工作交给player完成。

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

public class NewPlayerEvent : BaseEvent {
    private Player player;

    public override void Start()
    {
        base.Start();
        player = GetComponent<Player>();
    }
    public override void OnEvent(EventData eventData)
    {
        string username = (string)DictTool.GetValue<byte, object>(eventData.Parameters, (byte)ParameterCode.Username);
        player.OnNewPlayerEvent(username);
    }
}

2.3.2.3 同步其他客户端player位置事件(SyncPositionEvent.cs)

         在Stripts/Event下新建脚本SyncPositionEvent.cs,需要挂载到PlayerController,在面板上设置EventCode为SyncPosition需要发序列化得到其他客户端player的位置信息和名字,同步位置的工作交给player完成。

using System.Collections;
using System.Collections.Generic;
using ExitGames.Client.Photon;
using UnityEngine;
using Common;
using Common.Tools;
using System.Xml.Serialization;
using System.IO;

public class SyncPositionEvent : BaseEvent
{
    private Player player;

    public override void Start()
    {
        base.Start();
        player = GetComponent<Player>();
    }
    public override void OnEvent(EventData eventData)
    {
        string playDataListString =(string) DictTool.GetValue<byte, object>(eventData.Parameters,(byte)ParameterCode.PlayerDataList);

        //////将传来的被序列化的player名字和位置信息进行反序列化解析
        using (StringReader reader = new StringReader(playDataListString))
        {
            XmlSerializer serializer = new XmlSerializer(typeof(List<PlayerData>));
            List<PlayerData> playerDataList =(List<PlayerData>) serializer.Deserialize(reader);

            player.OnSyncPositionEvent(playerDataList);//交给player处理已经反序列化出的其他客户端player位置信息和名字(PlayerData)
        }
    }
}

2.3.3 控制player移动及同步其他客户端(player.cs)

    将Player物体作为预制体,在Scripts/AI 下新建脚本Player,挂载到PlayerController物体上,然后为playerPrefab指定为刚刚的预制体,player指定为场景中的Player.其中控制了player的移动,和请求、事件的调用。

using Common;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Common.Tools;

public class Player : MonoBehaviour {

    public string username;//用于区分不同的客户端player

    public GameObject playerPrefab;//用于实例化其他客户端player的预制体

    public GameObject player;//所控制的Player

    private SyncPositionRequest syncPosRequest;
    private SyncPlayerRequest syncPlayerRequest;

    private Vector3 lastPosition = Vector3.zero;//得到上次的位置信息,用于判断位置是否变化,无变化就不向服务器发送同步位置请求,节约性能
    private float moveOffest = 0.1f;//位置变化偏移量,大于此值就向服务器同步位置

    private Dictionary<string, GameObject> playerDict = new Dictionary<string, GameObject>();//储存创建的其他客户端player
	// Use this for initialization
	void Start () {
        
            syncPosRequest = GetComponent<SyncPositionRequest>();
            syncPlayerRequest = GetComponent<SyncPlayerRequest>();

            player.GetComponent<Renderer>().material.color = Color.green;//将本机的player颜色设置为绿色,用于区分本机和其他客户端player

            syncPlayerRequest.DefaultRequest();//同步其他玩家的位置

            InvokeRepeating("SyncPosition", 2, 0.05f);//同步位置,调用SyncPosition方法,2秒开始,每0.1秒调用一次

	}
    /// <summary>
    /// 同步自己位置到服务器
    /// </summary>
    void SyncPosition()
    {
        if(Vector3.Distance(lastPosition,player.transform.position)>moveOffest)
        {
            lastPosition= player.transform.position;

            syncPosRequest.pos = player.transform.position;

            syncPosRequest.DefaultRequest();
        }
    }

    // Update is called once per frame
    void Update () {
               //控制player移动
            float h = Input.GetAxis("Horizontal");
            float v = Input.GetAxis("Vertical");

           player.transform.Translate(new Vector3(h, 0, v) * Time.deltaTime *15);
	}
    /// <summary>
    /// 实例化出其他player(因为与OnNewPlayerEvent有重复了,直接调用OnNewPlayerEvent
    /// </summary>
    /// <param name="usernameList"></param>
    public void OnSyncPlayerResponse(List<string> usernameList)
    {
        //创建其他客户端的player角色
        foreach(string username in usernameList)
        {
            OnNewPlayerEvent(username);
        }
    }
    /// <summary>
    /// 处理服务器传来的实例化其他客户端事件
    /// </summary>
    public void OnNewPlayerEvent(string username)
    {
        GameObject go = GameObject.Instantiate(playerPrefab);
        playerDict.Add(username, go);
    }

    /// <summary>
    /// 处理服务器发来的同步其他客户端player位置和名字的事件
    /// </summary>
    /// <param name="playerDataList"></param>
    public void OnSyncPositionEvent(List<PlayerData> playerDataList)
    {
        //根据用户名遍历所有在线的游戏物体设置他们的位置
        foreach(PlayerData pd in playerDataList)
        {
            GameObject go = DictTool.GetValue<string, GameObject>(playerDict, pd.Username);

            if(go!=null)
                go.transform.position = new Vector3() { x = pd.Pos.x, y = pd.Pos.y, z = pd.Pos.z };
        }
    }
}

设置如上图。、     

最后打包出客户端exe。

3. 运行

打开MySQL数据库服务,打开PhotonServer文件夹下deploy\bin_Win64的PhotonControl.exe,启动自定义的应用——MyGame,再打开log,运行多个客户端。

猜你喜欢

转载自blog.csdn.net/a962035/article/details/80722538
今日推荐