Unity3D网络游戏实战——通用服务器框架

前言

网络游戏涉及客户端和服务端。服务端程序记录玩家数据,处理客户端发来的协议。本文就介绍一套通用客户端的实现。
该框架基于Select多路复用处理网络消息,具有粘包半包处理、心跳机制等功能,还是用MySQL数据库存储玩家数据,是一套功能较完备的C#服务端程序。一般单个服务端进程可以承载数百名玩家,如果更多就需要改为分布式架构。

7.1服务端架构

服务端两大核心是处理客户端的消息和存储玩家数据。
客户端与服务端通过TCP连接,使两者可以传递数据,服务端还连接着MySQL数据库,可将玩家数据保存到数据库中。

7.1.2模块划分

  • 逻辑层:消息处理,事件处理,存储结构。
  • 网络底层:连接客户端,消息处理,事件处理
  • 数据库底层:连接数据库,存储结构。

7.2Json编码解码

和客户端不同的是,因为服务端程序和Unity无关,无法使用JsonUtility,所以改用System.Web提供的方法实现。(需要手动引用System.web.Extensions)
在这里插入图片描述

新建一个控制台程序,将协议文件全部放在proto下。

7.2.3修改MsgBase

引入System.Web.Script.Serialization头文件后,和JsonUtility调用方法不同。因为JavaScriptSerializer不是静态类,需要定义一个该类型的对象,再让对象调用Serialize和Deserialize方法进行编码解码

7.3网络模块

7.3.1整体架构

  • 协议解析
  • 处理select多路复用的网络管理器NetManager(核心)
  • 定义客户端信息ClientState类
  • 处理网络消息的MsgHandler类,但是把不同消息类型的处理分拆到多个文件中。(BattleMsgHandler处理战斗协议、SysMsgHandler处理ping、pong协议)
  • 事件处理类EventHandler

7.3.2ClientState

客户端信息,每一个客户端连接对应一个ClientState,含有与客户端连接的套接字socket和读缓冲区readBuff,以及对应的玩家数据和最后一次收到ping的时间。

using System.Net.Sockets;

public class ClientState
{
	public Socket socket; 
	public ByteArray readBuff = new ByteArray(); 
	//Ping
	public long lastPingTime = 0;
	//玩家
	public Player player;
}

7.3.3开启监听和多路复用

客户端和服务端的NetManager功能相似,都是处理链接、粉发消息和网络事件。
但是为了管理多个连接,服务端采用了多路复用技术。

public static void StartLoop(int listenPort)
	{
		//Socket
		listenfd = new Socket(AddressFamily.InterNetwork,
			SocketType.Stream, ProtocolType.Tcp);
		//Bind
		IPAddress ipAdr = IPAddress.Parse("0.0.0.0");
		IPEndPoint ipEp = new IPEndPoint(ipAdr, listenPort);
		listenfd.Bind(ipEp);
		//Listen
		listenfd.Listen(0);
		Console.WriteLine("[服务器]启动成功");
		//循环
		while (true)
		{
			ResetCheckRead();  //重置checkRead
			Socket.Select(checkRead, null, null, 1000);
			//检查可读对象
			for (int i = checkRead.Count - 1; i >= 0; i--)
			{
				Socket s = checkRead[i];
				if (s == listenfd)
				{
					ReadListenfd(s);
				}
				else
				{
					ReadClientfd(s);
				}
			}
			//超时
			Timer();
		}
	}

服务端开启了端口监听后,进入循环。针对Select返回的列表,程序遍历它判断有新的客户端连接还是某个客户端发来消息,然后分别调用处理函数ReadListenfd和ReadClientfd。
当程序在Select有可读事件和超时都会调用Timer,空闲状态每秒调用一次。

处理监听消息以及处理客户端消息和前面写的都差不多,就不详细介绍了。

7.4心跳机制

我们在前面的clientstate已经加入了lastpingtime,注意是long,游戏客户端只运行几小时,unity提供的Time.time即可记录。但是服务端可能运行纪念,所以要用long保存。

7.4.2时间戳

时间戳是一种记录时间的方法,也就是1970年1月1日零点到现在的秒数。

//获取时间戳
	public static long GetTimeStamp()
	{
		TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
		return Convert.ToInt64(ts.TotalSeconds);
	}

7.4.3回应MsgPing协议

更新lastPingTime并回应

using System;

public partial class MsgHandler
{
    public static void MsgPing(ClientState c,MsgBase msgBase)
    {
        Console.WriteLine("MsgPing");
        c.lastPingTime = NetManager.GetTimeStamp();
        MsgPong msgPong = new MsgPong();
        NetManager.Send(c, msgPong);
    }
}

7.4.4超时处理

遍历客户端连接,太久没收到就断开连接,并删除clients列表元素。注意这是在遍历clients,删除后再遍历会出错,所以直接break。每次checkping最多断开一个连接。
在ontimer中调用,ontimer是在timer里通过反射调用,timer在startloop里调用

public static void OnTimer()
    {
        CheckPing();
    }

    //Ping检查
    public static void CheckPing()
    {
        //现在的时间戳
        long timeNow = NetManager.GetTimeStamp();
        //遍历,删除
        foreach (ClientState s in NetManager.clients.Values)
        {
            if (timeNow - s.lastPingTime > NetManager.pingInterval * 4)
            {
                Console.WriteLine("Ping Close " + s.socket.RemoteEndPoint.ToString());
                NetManager.Close(s);
                return;
            }
        }
    }

7.5玩家的数据结构

7.5.2Player

我们需要Player和PlayerData,Player里有id、socket,playerdata(存入数据库),临时坐标(无需存入数据库)

public class PlayerData
{
    //金币
    public int coin = 0;
    //记事本
    public string text = "new text";
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

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

    //construct
    public Player(ClientState state)
    {
        this.state = state;
    }
    //发送消息
    public void Send(MsgBase msgBase)
    {
        NetManager.Send(state, msgBase);
    }
}相互引用

7.5.3PlayerManager

用id作为player(字典)的key来获取player,而不是ip+port,因为随时会变。
NetManager.clients保存所有客户端信息(ClientState),PlayerManager.players保存所有玩家对象(player),客户端信息通过clientState.player引用玩家对象,玩家对象通过player.state引用客户端信息,两者相互引用配合。

7.6配置MySQL数据库

两部分

  1. 安装和启动MySQL服务器,让它监听某个端口
  2. 使用第三方库编码和解码MySQL特定形式的协议

7.6.1安装并启动MySQL数据库

这里安装配置好的集成环境xampp

XAMPP是apache 、mysql、PHP 的集成的web 服务器软件

简单地说就是个数据库服务器!
安装完成后点击MySQL后方的Start按钮即可。
在这里插入图片描述

7.6.2Navicat for MySQL

这是一套专为MySQL数据库服务的管理工具,可以用它建立数据库并查看数据表的内容,比直接使用MySQL的命令行语句方便。
安装好点击连接,新建连接,填入MySQL数据库的IP、端口用户名和密码,登陆数据库。
在这里插入图片描述
在这里我们自己创建game库,下面包含account和player两个表,分别记录id和password以及id和playerdata(都将键长度为20的id作为主键)。账号和游戏数据分开的好处是,一个账号可对应多个游戏的数据。

7.6.4安装connector

为解析MySQL的网络数据,我们需要引用connector这个第三方库,可以直接从书中资源下载。
添加MySql.Data.dll和System.Data.dll的依赖。

7.7数据库模块

在服务端编写DbManager.cs,用于处理数据库相关事务。提供从数据库读取玩家数据、将玩家数据保存到数据库、注册、检测密码是否正确等功能。
因为connector的.Net Framework版本是4.5.2,所以我们需要将服务端程序的框架也改为这个,防止引用不到MySql.Data。
在这里插入图片描述

在这里插入图片描述

7.7.1连接数据库

连接MySQL第一步就是发起对数据库的网络连接。connector封装了所有与数据库交互的方法。
在引用"MySql.Data.MySqlClient"后,新建一个MySQL连接对象,设置数据库、用户名、密码后调用Open即可发起连接。
该连接对象和socket相似,我们将数据库名、数据库IP、数据库端口号及用户名密码等组装成ConnectionString所需的格式再调用open即可。

public static MySqlConnection mysql;
    static JavaScriptSerializer Js = new JavaScriptSerializer();//用于调用一些序列化方法的对象

    //连接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;
        }
    }

先开启数据库服务器,再开启服务端程序,就可以显示连接成功。

7.1.2防止SQL注入

SQL注入,就是通过输入请求,把SQL命令插入到SQL语句中,已达到欺骗服务期执行恶意SQL命令的目的。比如取一个"xiaoming;delete * from player;"的名字,这样就会在操作这条用户名的时候删除player表。
所以为了防止SQL注入,我们把含有逗号、分号等特殊字符的字符串判定为不安全字符串,在拼装SQL语句前就进行安全监测。

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

7.7.3IsAccountExist

注册账号时判断账号是否已存在,不能再次注册。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;
        }
    }

7.7.4Register

通过insert into account set id=***, pw=***向account表插入数据。
但是一般不会吧明文的password存入数据库,而是先加密,比如加上md5加密,这样数据库被盗仍然不能从加密的密码获取用户信息。

 //注册
    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;
        }
    }

7.7.5CreatePlayer

1.将默认的PlayerData对象序列化成Json数据
2.将数据保存到player表的data栏位中

//创建角色
    public static bool CreatePlayer(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;
        }
    }

7.7.6CheckPassword

通过select * from account where id=*** and pw= ***查询数据库,如果有数据dataReader.HasRows==true说明id和pw正确。

7.7.7GetPlayerData

读取玩家数据,通过id在player表中搜寻数据,player表以id为key,以字符串的形式存放着序列化后的Json数据。
先用dataReader获取到对应账号的玩家数据,再将字符串反序列化PlayerData对象返回。

//创建角色
    public static bool CreatePlayer(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;
        }
    }

7.7.8UpdatePlayerData

将玩家数据playerData序列化成字符串,然后用形如"update player set data="{“coin”:100}" where id= “lpy”;"的SQL语句更新数据库中的数据

//保存角色
    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;
        }
    }

7.8登录注册功能

接下来用一个记事本跑通前面的流程。

7.8.1注册登陆协议

LoginMsg中包含了注册、登陆和踢出三条协议,均为服务端回应客户端的,一般有result和reason说明成功与否或者失败的原因

using System;
using System.Collections.Generic;

//注册
public class MsgRegister : MsgBase
{
    public MsgRegister() { protoName = "MsgRegister"; }
    //客户端发,协议内容
    public string id = "";
    public string pw = "";
    //服务端回(0-成功,1-失败)
    public int result = 0;
}

//登陆
public class MsgLogin : MsgBase
{
    public MsgLogin() { protoName = "MsgLogin"; }
    //客户端发
    public string id = "";
    public string pw = "";
    //服务端回(0-成功,1-失败)
    public int result = 0;
}

//踢下线(服务端推送)
public class MsgKick : MsgBase
{
    public MsgKick() { protoName = "MsgKick"; }
    //原因(0-其他人登陆同一账号)
    public int reason = 0;
}

7.8.3注册功能

添加LoginMsgHandle.cs,在其中编写MsgHandler(partial),先注册,再创建角色。

//注册协议处理
    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);
    }

7.8.4登陆功能

  1. 验证密码
  2. 状态判断(是否已经登陆)
  3. 踢下线,后上的踢掉先上的
  4. 读取playerData构建player对象,并加入到PlayerManager的列表中
//登陆协议处理
    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);
    }

7.8.9客户端监听

先把服务端的两个协议文件复制到客户端中,在start进行协议的监听!

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);
    }

7.8.10注册功能

//收到服务端发送的注册协议后,自动调用的回调方法
    public void OnMsgRegister(MsgBase msgBase)
    {
        MsgRegister msg = (MsgRegister)msgBase;
        if(msg.result == 0)
        {
            Debug.Log("注册成功");
        }
        else
        {
            Debug.Log("注册失败");
        }
    }

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

7.8.11登陆

//收到服务端发送的注册协议后,自动调用的回调方法
    public void OnMsgRegister(MsgBase msgBase)
    {
        MsgRegister msg = (MsgRegister)msgBase;
        if(msg.result == 0)
        {
            Debug.Log("注册成功");
        }
        else
        {
            Debug.Log("注册失败");
        }
    }

    //发送注册协议
    public void OnRegisterClick()
    {
        MsgRegister msg = new MsgRegister();
        msg.id = idInput.text;
        msg.pw = pwInput.text;
        NetManager.Send(msg);
    }
  • 13
    点赞
  • 102
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 《Unity3D网络游戏实战PDF》是一本非常重要的Unity3D开发书籍,它着重介绍了Unity3D游戏开发的网络部分。本书包含了网络游戏的核心概念、原理及实现方法,深入剖析了网络游戏的架构设计模式、游戏性能优化、安全防护等方面内容,并提供了实际的案例分析和设计思路,是网络游戏开发者的必备指南。 本书主要包括了以下内容:网络游戏的基础知识,如网络通信、协议、数据传输、数据整合等;Unity3D网络游戏的基本架构设计,如服务器端和客户端的架构设计、消息通信机制等;Unity3D游戏性能优化技巧,如消息压缩、消息缓存、负载均衡等;网络游戏的安全防护策略,如加密算法、防作弊、账号安全等;最后,作者还介绍了如何进行网络游戏的调试及问题排除。 《Unity3D网络游戏实战PDF》书籍内容深入浅出,适合初学者和中级开发者阅读。它提供了实用的方法和工具,帮助读者掌握网络游戏开发的技能,提高游戏开发的效率和质量。无论你是想开发网络游戏,还是想学习Unity3D游戏开发,本书都是不容错过的开发指南。 ### 回答2: unity3d网络游戏实战pdf是一本讲解使用Unity3D引擎开发网络游戏实战教程,内容涵盖了Unity3D的基础知识、网络编程与多人联机游戏设计。本书从基础开始,逐步讲解如何使用Unity3D引擎搭建网络游戏,包括如何进行多人联机游戏设计、实现网络通信,以及如何利用Unity3D引擎的特性实现游戏的界面设计和多人对战功能。本书不仅具有理论知识,更有大量实例和案例,可以帮助读者深刻了解Unity3D引擎的应用,提高网络游戏开发的技能和水平。这本书适合想要学习和掌握Unity3D引擎开发网络游戏的初、中级程序员、游戏开发者和爱好者阅读使用,可以帮助他们快速地了解并掌握开发网络游戏的流程和技巧。总之,如果你正在寻找一本全面且系统的Unity3D网络游戏开发教程,那么这本《unity3d网络游戏实战pdf》将是你的不二选择。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值