玩家的数据结构
当客户端连接服务端时,它还只是一个连接,只需要处理网络信息收发和心跳。当玩家输入用户名和密码,点击登录按钮之后,客户端会和某个游戏角色关联起来,因此需要一个数据结构来记录这些信息。(这里设置为player对象) 当玩家成功登录之后,程序会给player对象赋值,player对象包含id(帐号)等信息,代表一个游戏角色。游戏角色的某些数据需要保存到数据库而另一些则不需要。因此给Player对象定义一个PlayerData类型,它记录了所有需要保存到数据库的信息。
public class Player
{
public string id = "";
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;
public static bool Connect(string db,string ip,int port,string user,string pw)
{
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)
{
if (!DbManager.IsSafeString(id))
{
return false;
}
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)
{
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;
}
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)
{
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)
{
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)
{
if(!DbManager.IsSafeString(id))
{
Console.WriteLine("[数据库]GetPlayerData fail,id not safe");
return null;
}
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);
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 = "";
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相对复杂一些,要处理下列几项任务
- 验证密码:通过DbManager.CheckPassword验证用户名和密码,如果密码错误,返回result = 1给客户端
- 状态判断:如果该客户端已经登录,则不能重复登录
- 踢下线:通过PlayerManager.IsOnline判断该账户是否已经登录,如果已经登录需要将其踢下线,程序会通过PlayerManager.GetPlayer获取已经登录的玩家对象,给他发送MsgKick协议,通知被踢下线的客户端。最后滴哦用NetManager.Close关闭连接Socket连接
- 读取数据:通过DbManager.GetPlayerData从数据库中读取玩家数据
- 构建Player:根据读取到的数据,购件player对象,并且把它添加到PlayerManager的列表中,将客户端信息clientState和player对象关联起来
public class MsgLogin : MsgBase
{
public MsgLogin() { protoName = "MsgLogin"; }
public string id = "";
public string pw = "";
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 = 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");
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;
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;
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");
}
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);
}
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("保存失败");
}
}
}
小坑
-
这里需要注意的是,所有的proto文件尽量不要放在任何命名空间下,不然会使解码语句中的Type.GetType失效(因为这个好像要具体指定是哪一个命名空间下,所以只用using也是不行的。而具体指明是哪一个空间下可能会使别的协议失效) -
书上忘记在客户端连接上的时候设置lastPingTime为当前时间了,只需要简单加上即可state.lastPingTime = GetTimeStamp();
|