网络游戏涉及客户端和服务端。服务端程序记录玩家数据,处理客户端发来的协议。本文就介绍一套通用客户端的实现。
该框架基于Select多路复用处理网络消息,具有粘包半包处理、心跳机制等功能,还是用MySQL数据库存储玩家数据,是一套功能较完备的C#服务端程序。一般单个服务端进程可以承载数百名玩家,如果更多就需要改为分布式架构。
服务端两大核心是处理客户端的消息和存储玩家数据。
客户端与服务端通过TCP连接,使两者可以传递数据,服务端还连接着MySQL数据库,可将玩家数据保存到数据库中。
和客户端不同的是,因为服务端程序和Unity无关,无法使用JsonUtility,所以改用System.Web提供的方法实现。(需要手动引用System.web.Extensions)
新建一个控制台程序,将协议文件全部放在proto下。
引入System.Web.Script.Serialization头文件后,和JsonUtility调用方法不同。因为JavaScriptSerializer不是静态类,需要定义一个该类型的对象,再让对象调用Serialize和Deserialize方法进行编码解码
客户端信息,每一个客户端连接对应一个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;
}
客户端和服务端的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,空闲状态每秒调用一次。
处理监听消息以及处理客户端消息和前面写的都差不多,就不详细介绍了。
我们在前面的clientstate已经加入了lastpingtime,注意是long,游戏客户端只运行几小时,unity提供的Time.time即可记录。但是服务端可能运行纪念,所以要用long保存。
时间戳是一种记录时间的方法,也就是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);
}
更新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);
}
}
遍历客户端连接,太久没收到就断开连接,并删除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;
}
}
}
我们需要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);
}
}相互引用
用id作为player(字典)的key来获取player,而不是ip+port,因为随时会变。
NetManager.clients保存所有客户端信息(ClientState),PlayerManager.players保存所有玩家对象(player),客户端信息通过clientState.player引用玩家对象,玩家对象通过player.state引用客户端信息,两者相互引用配合。
两部分
这里安装配置好的集成环境xampp
XAMPP是apache 、mysql、PHP 的集成的web 服务器软件
简单地说就是个数据库服务器!
安装完成后点击MySQL后方的Start按钮即可。
这是一套专为MySQL数据库服务的管理工具,可以用它建立数据库并查看数据表的内容,比直接使用MySQL的命令行语句方便。
安装好点击连接,新建连接,填入MySQL数据库的IP、端口用户名和密码,登陆数据库。
在这里我们自己创建game库,下面包含account和player两个表,分别记录id和password以及id和playerdata(都将键长度为20的id作为主键)。账号和游戏数据分开的好处是,一个账号可对应多个游戏的数据。
为解析MySQL的网络数据,我们需要引用connector这个第三方库,可以直接从书中资源下载。
添加MySql.Data.dll和System.Data.dll的依赖。
在服务端编写DbManager.cs,用于处理数据库相关事务。提供从数据库读取玩家数据、将玩家数据保存到数据库、注册、检测密码是否正确等功能。
因为connector的.Net Framework版本是4.5.2,所以我们需要将服务端程序的框架也改为这个,防止引用不到MySql.Data。
连接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;
}
}
先开启数据库服务器,再开启服务端程序,就可以显示连接成功。
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, @"[-|;|,|\/|\(|\)|\[|\]|\}|]{|%|@|\*|!\']");
}
注册账号时判断账号是否已存在,不能再次注册。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;
}
}
通过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;
}
}
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;
}
}
通过select * from account where id=*** and pw= ***查询数据库,如果有数据dataReader.HasRows==true说明id和pw正确。
读取玩家数据,通过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;
}
}
将玩家数据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;
}
}
接下来用一个记事本跑通前面的流程。
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;
}
添加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);
}
//登陆协议处理
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);
}
先把服务端的两个协议文件复制到客户端中,在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);
}
//收到服务端发送的注册协议后,自动调用的回调方法
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);
}
//收到服务端发送的注册协议后,自动调用的回调方法
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);
}
文章浏览阅读3.5k次,点赞2次,收藏13次。为了从FTP服务器下载文件,需要要实现一个简单的FTP客户端。FTP(文件传输协议) 是 TCP/IP 协议组中的应用层协议。FTP协议使用字符串格式命令字,每条命令都是一行字符串,以“\r\n”结尾。客户端发送格式是:命令+空格+参数+"\r\n"的格式服务器返回格式是以:状态码+空格+提示字符串+"\r\n"的格式,代码只要解析状态码就可以了。读写文件需要登陆服务器,特殊用..._ftp 登录返回230
文章浏览阅读648次。前提:systemctl stop firewalld 关闭防火墙关闭selinux查看getenforce临时关闭setenforce 0永久关闭sed-i'/SELINUX/s/enforcing/disabled/'/etc/selinux/configselinux的三种模式enforcing:强制模式,SELinux 运作中,且已经正确的开始限制..._centos7 安装rabbitmq3.6.5
文章浏览阅读5.8k次。满意答案s55f2avsx2017.09.05采纳率:46%等级:12已帮助:5646人新版Android Studio/IntelliJ IDEA可以直接导入eclipse项目,不再推荐使用eclipse导出gradle的方式2启动Android Studio/IntelliJ IDEA,选择 import project3选择eclipse 项目4选择 create project f..._android studio 项目导入idea 看不懂安卓项目
文章浏览阅读860次,点赞2次,收藏6次。AI大模型技术已经在自然语言处理、计算机视觉、多模态交互等领域取得了显著的进展和成果,同时也引发了一系列新的挑战和问题,如数据质量、计算效率、知识可解释性、安全可靠性等。城市运维涉及到多个方面,如交通管理、环境监测、公共安全、社会治理等,它们需要处理和分析大量的多模态数据,如图像、视频、语音、文本等,并根据不同的场景和需求,提供合适的决策和响应。知识搜索有多种形式,如语义搜索、对话搜索、图像搜索、视频搜索等,它们可以根据用户的输入和意图,从海量的数据源中检索出最相关的信息,并以友好的方式呈现给用户。_ai大模型应用开发
文章浏览阅读8.2k次,点赞12次,收藏121次。为什么要测量阻抗呢?阻抗能代表什么?阻抗测量的注意事项... ...很多人可能会带着一系列的问题来阅读本文。不管是数字电路工程师还是射频工程师,都在关注各类器件的阻抗,本文非常值得一读。全文13000多字,认真读完大概需要2小时。一、阻抗测试基本概念阻抗定义:阻抗是元器件或电路对周期的交流信号的总的反作用。AC 交流测试信号 (幅度和频率)。包括实部和虚部。图1 阻抗的定义阻抗是评测电路、元件以及制作元件材料的重要参数。那么什么是阻抗呢?让我们先来看一下阻抗的定义。首先阻抗是一个矢量。通常,阻抗是_阻抗实部和虚部
文章浏览阅读955次。前面章节分享试用了pyzero,pygame但随着想增加更丰富的游戏内容,好多还要进行自己编写类,从今天开始解绍一个新的python游戏库arcade模块。通过此次的《连连看》游戏实现,让我对swing的相关知识有了进一步的了解,对java这门语言也有了比以前更深刻的认识。java的一些基本语法,比如数据类型、运算符、程序流程控制和数组等,理解更加透彻。java最核心的核心就是面向对象思想,对于这一个概念,终于悟到了一些。_arcade语言 like
文章浏览阅读1.1k次。源码简介与安装说明:2021增强版短视频去水印源码 去水印微信小程序源码网站 去水印软件源码安装环境(需要材料):备案域名–服务器安装宝塔-安装 Nginx 或者 Apachephp5.6 以上-安装 sg11 插件小程序已自带解析接口,支持全网主流短视频平台,搭建好了就能用注:接口是公益的,那么多人用解析慢是肯定的,前段和后端源码已经打包,上传服务器之后在配置文件修改数据库密码。然后输入自己的域名,进入后台,创建小程序,输入自己的小程序配置即可安装说明:上传源码,修改data/_去水印机要增强版
文章浏览阅读557次。1. 触发器是FPGA存储数据的基本单元2. 触发器作为时序逻辑的基本元件,官方提供了丰富的配置方式,以适应各种可能的应用场景。_fdre #(.init(1'b0) // initial value of register (1'b0 or 1'b1) ) fdce_osc (
文章浏览阅读560次。本该是不同编译器结果不同,但是尝试了g++ msvc都是先计算c,再计算b,最后得到a+b+c是经过赋值以后的b和c参与计算而不是6。由上表可知,将q复制到p数组可以表示为:*p++=*q++,*优先级高,先取到对应q数组的值,然后两个++都是在后面,该行运算完后执行++。在电脑端编译完后会分为text data bss三种,其中text为可执行程序,data为初始化过的ro+rw变量,bss为未初始化或初始化为0变量。_嵌入式面试笔试c语言知识点
文章浏览阅读2.3k次。57 Things I've Learned Founding 3 Tech CompaniesJason Goldberg, Betashop | Oct. 29, 2010, 1:29 PMI’ve been founding andhelping run techn_mature
文章浏览阅读1.9k次。问题:先讲下需求,有若干个文本文件(txt或者csv文件等),每行代表一条数据,现在希望能合并成 1 个文本文件,且需要去除重复行。分析:一向奉行简单原则,如无必要,绝不复杂。如果数据量不大,那么如下两条命令就可以搞定合并:cat a.txt >> new.txtcat b.txt >> new.txt……去重:cat new...._python 超大文本合并
文章浏览阅读489次。这个过渡页是第一次打开小程序展示的,点击某个小程序前把手机的开发者->network link conditioner->enable & very bad network 就会在停在此页。比如《支付宝运动》这个小程序先看这个类的.h可以看到它继承于DTViewController点击左上角返回的方法- (void)back;#import "DTViewController.h"#import "APBaseLoadingV..._类似支付宝页面过度加载页