【学习总结】通用服务端框架(二)

玩家的数据结构

当客户端连接服务端时,它还只是一个连接,只需要处理网络信息收发和心跳。当玩家输入用户名和密码,点击登录按钮之后,客户端会和某个游戏角色关联起来,因此需要一个数据结构来记录这些信息。(这里设置为player对象)
当玩家成功登录之后,程序会给player对象赋值,player对象包含id(帐号)等信息,代表一个游戏角色。游戏角色的某些数据需要保存到数据库而另一些则不需要。因此给Player对象定义一个PlayerData类型,它记录了所有需要保存到数据库的信息。

    public class Player
    {
        //id
        public string id = "";
        //指向ClientState
        public ClientState state;
        //临时数据 如:当前坐标
        public int x;
        public int y;
        public int z;
        //数据库数据
        public PlayerData data;

        //构造函数
        public Player(ClientState state)
        {
            this.state = state;
        }
        //发送信息
        public void Send(MsgBase msgBase)
        {
            NetManager.Send(state, msgBase);
        }
    }
    public class PlayerData
    {
        //金币
        public int coin;
        //记事本
        public string text = "new text";
    }
}

上述代码制定了指向客户端信息的state,它需要在构造函数中赋值,用于指向持有player对象的clientState。添加state成员是为了方便逻辑功能的实现,比如处理玩家A攻击了玩家B协议时,程序只需要根据玩家B的id找到对应的clientState给它发送通知。程序只需要找到玩家B的Player对象,再调用NetManager.Send

PlayerMannager

由于我们在发送协议的时候,需要根据ID去找到对应的玩家来完成一系列的操作,当玩家人数众多的时候,遍历去找到ClientState是一个非常费时的操作。所以我们这里采用字典去快速索引ClientState

    public class PlayerManager
    {
        //玩家列表
        static Dictionary<string, Player> players = new Dictionary<string, Player>();
        //玩家是否在线
        public static bool IsOnline(string id)
        {
            return players.ContainsKey(id);
        }
        //获取玩家
        public static Player GetPlayer(string id)
        {
            if (players.ContainsKey(id))
            {
                return players[id];
            }
            return null;
        }
        //添加玩家
        public static void AddPlayer(string id,Player player)
        {
            players.Add(id,player);
        }
        //删除玩家
        public static void RemovePlayer(string id)
        {
            players.Remove(id);
        }
    }

小结

总结一下这里控制角色的方式。首先是每个连接上的ClientState中都含有一个Player对象,客户端通过用户名和密码对该Player对象进行赋值,得到自己该操作的对象。NetMannger.clients保存着所有客户端的信息(clientState),而PlayerManager保存着所有的玩家对象(player)。客户端信息通过clientState.player来引用玩家对象,玩家对象通过player.state引用客户端信息。(这里算是一种循环引用)

数据库

使用的数据库是MySQL, 建立了两张表account和player,account具有id和pw两个属性,player具有id和data两个属性。我们一般都是将账户信息和玩家信息分开的,因为一个账户可能拥有多个游戏的不同玩家信息。然后在程序中引用MySql.Data.dll(Visual Studio 带有这个库,搜索打上勾即可)

连接数据库

连接MySQL数据库的第一步是发起对数据库的网络连接。Connector已经封装了所有与数据库交互的方法,在引用“MySql.Data.MySqlClient”后,新建一个MySQL连接对象,设置数据库、用户名和密码等信息后,调用mysql.open即可发起连接

    public class DbManager
    {
        public static MySqlConnection mysql;

        //连接mysql数据库
        public static bool Connect(string db,string ip,int port,string user,string pw)
        {
            //创建MySqlConnection对象
            mysql = new MySqlConnection();
            //连接参数
            string s = string.Format("Database={0};Data Source={1};port={2};User Id={3};Password={4}",db,ip,port,user,pw);
            mysql.ConnectionString = s;
            //连接
            try
            {
                mysql.Open();
                Console.WriteLine("[数据库]connect succ");
                return true;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库]connect fail," + e.Message);
                return false;
            }
        }
    }

防止SQL注入

这个功能是为了避免玩家恶意注册SQL相关的ID导致SQL执行错误,所以需要对用户输入的字符串进行安全性检测,以便有效防止SQL注入。

        //判定安全字符串
        private static bool IsSafeString(string str)
        {
            return !Regex.IsMatch(str,@"[-|;|,|\/|\(|\)|\[|\]|\{|\}|%|@|\*|!|\']");
        }

注册

当玩家注册帐号时,程序需要判断帐号是否已经存在,如果存在,就返回错误信息。DbManager的IsAccountExist方法将会查询数据库,如果数据库中已经存在该用户,则不能再次注册。MySqlDataReader提供遍历数据集的方法,HasRows指明数据集是否包含数据。在数据库模块中,所有由玩家输入的字符串都需要做安全检测,以免被黑掉

        //是否存在该用户
        public static bool IsAccountExist(string id)
        {
            //防止SQL注入
            if (!DbManager.IsSafeString(id))
            {
                return false;
            }
            //SQL
            string s = string.Format("select * from account where id='{0}';",id);
            //查询
            try
            {
                MySqlCommand cmd = new MySqlCommand(s, mysql);
                MySqlDataReader dataReader = cmd.ExecuteReader();
                bool hasRows = dataReader.HasRows;
                dataReader.Close();
                return !hasRows;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库] IsSafeString err,"+ e.Message);
                return false;
            }
        }

当判定无重复帐号之后,玩家可进入注册流程。程序会调用Register方法完成注册流程,它会先做一系列判断,然后由通过SQL语句向account表插入数据。在磁盘空间已经饱满,SQL语句写错等情况下,插入数据会失败然后抛出异常,因此需要try-catch包围

        //注册
        public static bool Register(string id,string pw)
        {
            //防止SQL注入
            if(!DbManager.IsSafeString(id))
            {
                Console.WriteLine("[数据库]Register fail,id not safe");
                return false;
            }
            if(!DbManager.IsSafeString(pw))
            {
                Console.WriteLine("[数据库]Register fail,pw not safe");
                return false;
            }
            //能否注册
            if(!IsAccountExist(id))
            {
                Console.WriteLine("[数据库]Register fail,id exist");
                return false;
            }
            //写入数据库User表
            string sql = string.Format("insert into account set id = '{0}',pw = '{1}';",id,pw);
            try
            {
                MySqlCommand cmd = new MySqlCommand(sql, mysql);
                cmd.ExecuteNonQuery();
                return true;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库] Register fail" + e.Message);
                return false;
            }
        }

关联Player

Register方法只是将用户名和密码写入account表,服务端中account和player是对应的,程序还需要将默认的角色数据写入player表。创建角色包含两个步骤,一个是将默认的PlayerData对象序列化成Json数据,二是将数据保存到player表的data栏位中:

        //创建角色
        public static bool CreatePlyaer(string id)
        {
            //防止SQL注入
            if(!DbManager.IsSafeString(id))
            {
                Console.WriteLine("[数据库] CreatePlayer fail,id not safe");
                return false;
            }
            //序列化
            PlayerData playerData = new PlayerData();
            string data = Js.Serialize(playerData);
            //写入数据库
            string sql = string.Format("insert into player set id = '{0}',data = '{1}';",id,data);
            try
            {
                MySqlCommand cmd = new MySqlCommand(sql, mysql);
                cmd.ExecuteNonQuery();
                return true;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库]CreatePlayer err," + e.Message);
                return false;
            }
        }

密码检测

服务端需要检测用户的用户名和和密码是否正确

        //检测用户名和密码
        public static bool CheckPassword(string id,string pw)
        {
            //防止sql注入
            if (!DbManager.IsSafeString(id))
            {
                Console.WriteLine("[数据库]CheckPassword fail,id not safe");
                return false;
            }
            if(!DbManager.IsSafeString(pw))
            {
                Console.WriteLine("[数据库]CheckPassword fail,pw not safe");
            }
            //查询
            string sql = string.Format("select * from account where id='{0}' and pw='{1}';",id,pw);
            try
            {
                MySqlCommand cmd = new MySqlCommand(sql, mysql);
                MySqlDataReader dataReader = cmd.ExecuteReader();
                bool hasRows = dataReader.HasRows;
                dataReader.Close();
                return hasRows;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库]CheckPassword err," + e.Message);
                return false;
            }
        }

获取玩家数据

该模块通过角色帐号(id)在player表中搜寻数据,player表以id为key,以字符串的形式存储Json数据。程序通过dataReader获取到对应玩家的数据之后,使用JS.Deserialize将字符串反序列化成PlayerData对象

        //获取玩家数据
        public static PlayerData GetPlayerData(string id)
        {
            //防止SQL注入
            if(!DbManager.IsSafeString(id))
            {
                Console.WriteLine("[数据库]GetPlayerData fail,id not safe");
                return null;
            }

            //SQL
            string sql = string.Format("select * from player where id ='{0}';",id);
            try
            {
                //查询
                MySqlCommand cmd = new MySqlCommand(sql,mysql);
                MySqlDataReader dataReader = cmd.ExecuteReader();
                if (!dataReader.HasRows)//如果没有这一行数据
                {
                    dataReader.Close();
                    return null;
                }
                //读取
                dataReader.Read();
                string data = dataReader.GetString("data");
                //反序列化
                PlayerData playerData = Js.Deserialize<PlayerData>(data);
                dataReader.Close();
                return playerData;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库]GetPlayerData fail," + e.Message);
                return null;
            }
        }

更新玩家数据

        //保存角色
        public static bool UpdatePlayerData(string id,PlayerData playerData)
        {
            //序列化
            string data = Js.Serialize(playerData);
            //sql
            string sql = string.Format("update player set data='{0}' where id = '{1}';",data,id);
            //更新
            try
            {
                MySqlCommand cmd = new MySqlCommand(sql,mysql);
                cmd.ExecuteNonQuery();
                return true;
            }
            catch(Exception e)
            {
                Console.WriteLine("[数据库]UpdatePlayerData err," + e.Message);
                return false;
            }
        }

记事本程序

登录注册与退出

为了试验服务端和客户端框架,将做一个记事本程序来跑通整个游戏程序
从客户端的角度来看,在线记事本至少需要4条协议。MsgRegister和MsgLogin是注册和登录协议。登陆后,客户端需要现实已经保存的文本信息,它通过MsgGetText获取文本,编辑文本后,玩家点击保存按钮,客户端发送MsgSaveText协议

    //注册协议
    public class MsgRegister : MsgBase
    {
        public MsgRegister() { protoName = "MsgRegister"; }
        //客户端发送
        public string id = "";
        public string pw = "";
        //服务端回复(0代表成功,1代表失败)
        public int result = 0;
    }
        //注册协议处理
        public static void MsgRegister(ClientState c, MsgBase msgBase)
        {
            MsgRegister msg = (MsgRegister)msgBase;
            //注册
            if (DbManager.Register(msg.id, msg.pw))
            {
                DbManager.CreatePlayer(msg.id);
                msg.result = 0;
            }
            else
            {
                msg.result = 1;
            }
            NetManager.Send(c, msg);
        }

处理登录协议的方法MsgLogin相对复杂一些,要处理下列几项任务

  1. 验证密码:通过DbManager.CheckPassword验证用户名和密码,如果密码错误,返回result = 1给客户端
  2. 状态判断:如果该客户端已经登录,则不能重复登录
  3. 踢下线:通过PlayerManager.IsOnline判断该账户是否已经登录,如果已经登录需要将其踢下线,程序会通过PlayerManager.GetPlayer获取已经登录的玩家对象,给他发送MsgKick协议,通知被踢下线的客户端。最后滴哦用NetManager.Close关闭连接Socket连接
  4. 读取数据:通过DbManager.GetPlayerData从数据库中读取玩家数据
  5. 构建Player:根据读取到的数据,购件player对象,并且把它添加到PlayerManager的列表中,将客户端信息clientState和player对象关联起来
    //登录协议
    public class MsgLogin : MsgBase
    {
        public MsgLogin() { protoName = "MsgLogin"; }
        //客户端发送
        public string id = "";
        public string pw = "";
        //服务器回复(0代表成功,1代表失败)
        public int result = 0;
    }
        //登录协议处理
        public static void MsgLogin(ClientState c, MsgBase msgBase)
        {
            MsgLogin msg = (MsgLogin)msgBase;
            //密码校验
            if (!DbManager.CheckPassword(msg.id, msg.pw))
            {
                msg.result = 1;
                NetManager.Send(c, msg);
                return;
            }
            //不允许再次登录
            if (c.player != null)
            {
                msg.result = 1;
                NetManager.Send(c, msg);
                return;
            }
            //如果已经登录,则踢下线
            if (PlayerManager.IsOnline(msg.id))
            {
                //发送踢下线协议
                Player other = PlayerManager.GetPlayer(msg.id);
                MsgKick msgKick = new MsgKick();
                msgKick.reason = 0;
                other.Send(msgKick);
                //断开连接
                NetManager.Close(other.state);
            }
            //获取玩家数据
            PlayerData playerData = DbManager.GetPlayerData(msg.id);
            if (playerData == null)
            {
                msg.result = 1;
                NetManager.Send(c, msg);
                return;
            }
            //构建Player
            Player player = new Player(c);
            player.id = msg.id;
            player.data = playerData;
            PlayerManager.AddPlayer(msg.id, player);
            c.player = player;
            //返回协议
            msg.result = 0;
            player.Send(msg);
        }

退出功能就相对简单一些了,保存完玩家数据然后移除即可

        public static void OnDisconnect(ClientState c)
        {
            Console.WriteLine("Close");
            //Player下线
            if(c.player != null)
            {
                //保存数据
                DbManager.UpdatePlayerData(c.player.id, c.player.data);
                //移除
                PlayerManager.RemovePlayer(c.player.id);
            }
        }

记事本协议

客户端发送完MsgGetText协议后,服务端返回带有test字段的同名协议,返回记事本文本。编辑完文本后,玩家点击保存按钮,客户端会发送MsgSaveText协议,并且将修改后的文本以text字段发送给服务端,服务端收到后,更新文本,并且返回同名协议,如果result为0,则代表保存成功

        //获取记事本内容
        public static void MsgGetText(ClientState c,MsgBase msgBase)
        {
            MsgGetText msg = (MsgGetText)msgBase;
            Player player = c.player;
            if (player == null) return;
            //获取text
            msg.text = player.data.text;
            player.Send(msg);
        }
        //保存记事本内容
        public static void MsgSaveText(ClientState c,MsgBase msgBase)
        {
            MsgSaveText msg = (MsgSaveText)msgBase;
            Player player = c.player;
            if (player == null) return;
            //获取text
            player.data.text = msg.text;
            player.Send(msg);
        }

客户端

界面如下所示
在这里插入图片描述
测试脚本:

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

public class test : MonoBehaviour
{
    //获取相关组件
    public InputField idInput;
    public InputField pwInput;
    public InputField textInput;
    //开始
    void Start()
    {
        NetManager.AddEventListener(NetManager.NetEvent.ConnectSucc,OnConnectSucc);
        NetManager.AddEventListener(NetManager.NetEvent.ConnectFail,OnConnectFail);
        NetManager.AddEventListener(NetManager.NetEvent.Close,OnConnectClose);
        NetManager.AddMsgListener("MsgMove",OnMsgMove);
        NetManager.AddMsgListener("MsgRegister",OnMsgRegister);
        NetManager.AddMsgListener("MsgLogin",OnMsgLogin);
        NetManager.AddMsgListener("MsgKick",OnMsgKick);
        NetManager.AddMsgListener("MsgGetText",OnMsgGetText);
        NetManager.AddMsgListener("MsgSaveText",OnMsgSaveText);
    }

    //玩家点击连接按钮
    public void OnConnectClick(){
        NetManager.Connect("127.0.0.1",8888);
    }

    //玩家点击断开连接按钮
    public void OnCloseClick(){
        NetManager.Close();
    }
    //玩家点击移动按钮
    public void OnMoveClick(){
        MsgMove msg = new MsgMove();
        msg.x = 120;
        msg.y = 123;
        msg.z = -6;
        NetManager.Send(msg);
    }
    
    //回调函数
    //连接成功回调
    void OnConnectSucc(string err)
    {
        Debug.Log("OnConnectSucc");
    }
    //连接失败回调
    void OnConnectFail(string err)
    {
        Debug.Log("OnConnectFail" + err);
    }
    //关闭连接
    void OnConnectClose(string err)
    {
        Debug.Log("OnConnectClose");
    }

    //收到MsgMove协议
    public void OnMsgMove(MsgBase msgBase){
        MsgMove msg = (MsgMove)msgBase;
        //消息处理
        Debug.Log("OnMsgMove msg.x = " + msg.x);
        Debug.Log("OnMsgMove msg.y = " + msg.y);
        Debug.Log("OnMsgMove msg.z = " + msg.z);
    }

    //update
    public void Update(){
        NetManager.Update();
    }
    
    //发送注册协议
    public void OnRegisterClick()
    {
        MsgRegister msg = new MsgRegister();
        msg.id = idInput.text;
        msg.pw = pwInput.text;
        NetManager.Send(msg);
    }

    //收到注册协议
    public void OnMsgRegister(MsgBase msgBase)
    {
        MsgRegister msg = (MsgRegister)msgBase;
        if(msg.result == 0)
        {
            Debug.Log("注册成功");
        }
        else{
            Debug.Log("注册失败");
        }
    }

    //发送登录协议
    public void OnLoginClick()
    {
        MsgLogin msg = new MsgLogin();
        msg.id = idInput.text;
        msg.pw = pwInput.text;
        NetManager.Send(msg);
    }
    //收到登录协议
    public void OnMsgLogin (MsgBase msgBase)
    {
        MsgLogin msg = (MsgLogin)msgBase;
        if(msg.result == 0)
        {
            Debug.Log("登录成功");
            //请求记事本
            MsgGetText msgGetText = new MsgGetText();
            NetManager.Send(msgGetText);
        }
        else{
            Debug.Log("登录失败");
        }
    }
    //被踢下线
    void OnMsgKick(MsgBase msgBase)
    {
        Debug.Log("被踢下线");
    }
    //收到记事本协议
    public void OnMsgGetText(MsgBase msgBase)
    {
        MsgGetText msg = (MsgGetText)msgBase;
        textInput.text = msg.text;
    }
    //发送保存协议
    public void OnSaveClick()
    {
        MsgSaveText msg = new MsgSaveText();
        msg.text = textInput.text;
        NetManager.Send(msg);
    }
    //收到保存协议
    public void OnMsgSaveText(MsgBase msgBase)
    {
        MsgSaveText msg = (MsgSaveText) msgBase;
        if(msg.result == 0)
        {
            Debug.Log("保存成功");
        }
        else{
            Debug.Log("保存失败");
        }
    }
}

小坑

  1. 这里需要注意的是,所有的proto文件尽量不要放在任何命名空间下,不然会使解码语句中的Type.GetType失效(因为这个好像要具体指定是哪一个命名空间下,所以只用using也是不行的。而具体指明是哪一个空间下可能会使别的协议失效)

  2. 书上忘记在客户端连接上的时候设置lastPingTime为当前时间了,只需要简单加上即可state.lastPingTime = GetTimeStamp();

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值