使用C#和Unity实现的多人聊天室2

GIF 2020-8-5 16-16-52

客户端部分

NetMgr类

该类负责处理与服务端的连接,包含一个Connection类的静态成员,封装了同服务端大致相同的消息处理方法,客户端内的其他组件可以直接访问该成员来使用消息发送等功能。当客户端接收到消息时,会通过Connection类成员的方法将收到的消息挂载在消息队列里,接着消息分发类再每帧从里面抽出一条消息来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Connection
{
public MsgDistribution msgDist = new MsgDistribution();

public void Update()
{
...
msgDist.Update();
...
}
//接收到消息的异步回调,同服务端
public ReceiveCb(IAsyncResult ar)
{
...
ProcessData();
...
}

public void ProcessData()
{
...
//协议解码
ProtocolBase protocol = proto.Decode(readBuff, sizeof(Int32), msgLength);
//由于是异步进行所以在修改消息队列时需要给消息队列加锁
lock(msgDist.msgList)
{
msgDist.msgList.Add(protocol);
}
...
}
}

MsgDistribution类

该类负责消息分发,包含以下成员

1
2
3
4
5
6
7
8
9
//每帧处理消息数量
public int num = 15;
//消息列表
public List<ProtocolBase> msgList = new List<ProtocolBase>();
//委托类型
public delegate void Delegate(ProtocolBase proto);
//事件监听表,once表注册一次监听一次,event表注册一次终身执行
private Dictionary<string, Delegate> eventDict = new Dictionary<string, Delegate>();
private Dictionary<string, Delegate> onceDict = new Dictionary<string, Delegate>();
委托

委托在C#中使用很多,它可以将某个方法作为参数传给另一个方法,比如说

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//有两个方法,它们接受的参数一样
class Program
{
private static void Method1(string name)
{
Console.WriteLine("method1" + name);
}
private static void Method2(string name)
{
Console.WriteLine("method2" + name);
}
//可以定义一个委托类型
private delegate void Method(string name);
//就可以在其他方法中以参数的形式调用了
private static void Event(string name, Method anyMethod)
{
anyMethod(name);
}
static void Main(string[] args)
{
Event("aaa", Method1); //method1aaa
Event("bbb", Method2); //method2bbb
}
}

用了委托类型后还有一个方便的特性,就是可以使用+-运算符来修改委托里的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Program
{
private static int Method1(ref int num)
{
return num += 2;
}
private static int Method2(ref int num)
{
return num += 3;
}
private delegate int Method(ref int num);
static void Main(string[] args)
{
int num = 5;
Method mm = new Method(Method1);
mm += Method2;
Console.WriteLine(mm(ref num)); //5+2+3=10返回的是最后一个方法的返回值
}
}

额外补充,使用委托时也可以使用匿名方法,例如上面的主函数部分可以改成

1
2
3
4
5
6
7
static void Main(string[] args)
{
int num = 5;
Method mm = new Method(Method1);
mm += delegate(ref int num) { return num += 3; };
Console.WriteLine(mm(ref num)); //5+2+3=10返回的是最后一个方法的返回值
}

或者使用Lambda表达式

1
2
3
4
5
6
7
static void Main(string[] args)
{
int num = 5;
Method mm = new Method(Method1);
mm += (ref int num) => { return num += 3; };
Console.WriteLine(mm(ref num)); //5+2+3=10返回的是最后一个方法的返回值
}
事件监听

类成员包括了两个字典,存储的是名字和委托的键值对,这样可以方便的把每种协议和它所对应的处理方法存储起来。此处分为了两个字典,一个用来监听一次性协议,如登录注册,另一个用来监听长期协议,如玩家位置更新

1
2
private Dictionary<string, Delegate> eventDict = new Dictionary<string, Delegate>();
private Dictionary<string, Delegate> onceDict = new Dictionary<string, Delegate>();

在Connection类中的发送方法中,需要监听服务端的返回信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Connection
{
MsgDistribution msgDist = new MsgDistribution();
...
public bool Send(ProtocolBase protocol, string cbName,MsgDistribution.Delegate cb)
{
if (status != Status.Connected) return false;
msgDist.AddOnceListener(cbName, cb);
return Send(protocol);
}
...
}

class MsgDistribution
{
...
public void AddOnceListener(string name, Delegate cb)
{
if (onceDict.ContainsKey(name))
{
onceDict[name] += cb;
}
else
{
onceDict[name] = cb;
}
}
...
}
//接下来只需要在登陆面板的脚本里设定登录协议的回调函数并调用即可
class LoginPanel
{
public void OnLoginClick()
{
if(idInput.text == "" || pwInput.text == "")
{
Debug.Log("用户名密码不能为空!");
return;
}
if(NetMgr.srvConn.status != Connection.Status.Connected)
{
Debug.Log("网络出问题了");
return;
}
ProtocolBytes protocol = new ProtocolBytes();
protocol.AddString("Login");
protocol.AddString(idInput.text);
protocol.AddString(pwInput.text);
//Send方法中添加了事件监听
NetMgr.Send(protocol, OnLoginBack);
}
public void OnLoginBack(ProtocolBase protocol)
{
//定义回调函数
}
}

接下来只需要在Update中从消息队列中依次取出消息读取即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class MsgDistribution
{
//消息列表
public List<ProtocolBase> msgList = new List<ProtocolBase>();
public void Update()
{
for (int i = 0; i < num; i++)
{
if (msgList.Count > 0)
{
DispatchMsgEvent(msgList[0]);
lock (msgList) msgList.RemoveAt(0);
}
else
{
break;
}
}
}
public void DispatchMsgEvent(ProtocolBase protocol)
{
string name = protocol.GetName();
Debug.Log("分发处理消息" + name);
if (eventDict.ContainsKey(name))
{
//获取协议对应的处理方法,并传入参数protocol
eventDict[name](protocol);
}
if (onceDict.ContainsKey(name))
{
onceDict[name](protocol);
onceDict[name] = null;
onceDict.Remove(name);
}
}
}

PlayerMgr类

用来管理客户端游戏场景中的所有玩家,包括玩家信息同步,玩家生成,根据实体是本机还是网络玩家对预设进行修改等等

PanelMgr类

一种UI实现方式

用来管理所有UI页面,项目里依据书籍实现了一个拓展性很强的UI实现方式,定义了一个所有面板的基类,然后在PanelMgr类里定义了打开方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void OpenPanel<T>(string skinPath,params object[] args) where T : PanelBase
{
string name = typeof(T).ToString();
if (dict.ContainsKey(name)) return;
//面板脚本
PanelBase panel = canvas.AddComponent<T>();
panel.Init(args);
dict.Add(name, panel);
//加载皮肤
skinPath = (skinPath != "" ? skinPath : panel.skinPath);
GameObject skin = Resources.Load<GameObject>("Panel/"+skinPath);
if (skin == null) Debug.LogError("panelMgr.OpenPanel fail,skin is null,skinPath=" + skinPath);
panel.skin = (GameObject)Instantiate(skin);
//坐标
Transform skinTrans = panel.skin.transform;
PanelLayer layer = panel.layer;
Transform parent = layerDict[layer];
skinTrans.SetParent(parent, false);
//panel的生命周期
panel.OnShowing();
panel.OnShowed();
}

当需要打开标题页面时

1
PanelMgr.instance.Open<TitlePanel>("");

就会给Canvas物体挂载上TitlePanel脚本

image-20200805180945797

同时找到TitlePanel的预设并生成,将Canvas下的Panel设置为其父级物体,此时前面TitlePanel脚本中的skin就指向该物体,就可以获取到里面的输入框等组件

image-20200805181129381

具体的游戏中流程及协议的内容

注册协议

image-20200806225115279

客户端发送的协议为

协议名称(String) 账号(String) 密码(String)
Register userId password

对应的代码片段如下,AddString()内部会计算字符串的字节长度并和字符串组合转化为字节数组bytes[]

1
2
3
4
ProtocolBytes protocol = new ProtocolBytes();
protocol.AddString("Register");
protocol.AddString(idInput.text);
protocol.AddString(pwInput.text);

服务端发送的返回协议和代码片段如下,其会使用DataMgr的封装方法进行注册,在DataMgr内部进行判断该账号是否注册成功,方法的返回值为bool型,成功后就在协议后面加上状态码0,不成功加上-1

协议名称(String) 执行状态(Int)
Register 0
1
2
3
4
5
6
7
8
9
10
11
protocol = new ProtocolBytes();
protocol.AddString("Register");
//注册
if (DataMgr.instance.Register(id, pw))
{
protocol.AddInt(0);
}
else
{
protocol.AddInt(-1);
}

当客户端接收到后,再取出状态码来执行不同的操作

状态码 操作
0 注册成功,关闭注册面板,返回登录面板
-1 注册失败,保留注册面板

登录协议

登录协议同注册协议相似,发送的协议内容为

协议名称(String) 账号(String) 密码(String)
Login userId password

服务端返回协议为同注册的一样,成功返回状态码0,失败则返回状态码-1,但客户端需要知道为什么注册失败并进行下一步操作,故状态码修改为

状态码 操作
0 登录成功,关闭登录面板,场景中生成角色
-1 密码错误,保留登录页面
-2 这个id已在线,客户端需要把场景里该id代表的玩家删掉,也就是平常挤下线的意思
-3 这个账号在数据库没有角色数据,客户端需要拉起创建角色面板

创建角色协议

当玩家登陆后发现服务器内没有角色数据时,需要创建新角色,玩家输入信息后,发送创建角色协议

协议名称(String) 名字(String)
CreatePlayer name

登录成功了以后,服务端的Player类已经保存了玩家的id,目前只是Player类下面的PlayerData成员为空,接下来的协议就不需要把id一同发送给服务端了

返回协议也同上面的一样

状态码 操作
0 创建角色成功
-1 创建角色失败,保留创建角色面板

获取玩家列表协议

这个稍微有点特殊,客户端发送的协议只有一个协议名称

协议名称(String)
GetList

服务端接收后,会在Scene类中获取场景中所有玩家的信息,并构建这样一个返回协议

协议名称(String) 玩家数量(Int) 玩家1_x(Float) 玩家1_y(Float) 玩家1_z(Float) 玩家1_xScale(Int) 玩家1_animInfo(Int) 玩家1_name(String) 玩家2_x(Int)
GetList 2 x1 y1 z1 1 0 name x1

首先客户端会读取到玩家数量,然后循环读取后面的玩家信息,项目中是六条数据为一组,除了用作位置同步的x,y,z外,由于动画只做了向右运动的版本,玩家往左移动时直接将角色的Scale取相反数,所以需要有xScale来表示角色的朝向,animInfo用于辅助动画同步,name为角色的属性,目前项目中角色的属性只有name一项,可以改成血量,攻击力之类的

信息同步协议

image-20200806233312844

项目中设置为每过0.3s,客户端向服务端发送一次自己的信息,内容同上面GetList协议一样

协议名 位置x 位置y 位置z 角色朝向 动画状态 名字
UpdateInfo x y z xScale animInfo name

当服务端收到以后,先更新服务端里的角色信息,再构建返回协议,这次返回协议不是返还给发送方,而是加上发送方的玩家id并广播给其他所有的客户端

协议名 id 位置x 位置y 位置z 角色朝向 动画状态 名字
UpdateInfo userId x y z xScale animInfo name

客户端接收到后就更新场景中id对应的角色的信息即可

更改名字协议

发送:

协议名称(String) 名字(String)
EditName name

返回:

协议名称(String) 状态(Int)
EditName 0
状态码 操作
0 修改名字成功,直接在客户端修改自己角色的名字显示,关闭修改信息面板
-1 修改名字失败,保留修改信息面板

聊天协议

跟信息同步的协议类似,玩家发送聊天信息后,需要广播给服务器上的所有玩家,同时将语句显示在聊天气泡和聊天框里,聊天框的制作忘记了可以再查看源码,这里就不详细写了

发送:

协议名称(String) 文本(String)
SendChat text

广播:

协议名称(String) id(String) 文本(String)
PlayerChat userId text

项目仍需改进之处

位置同步

目前项目内是每0.3s发送一次包,所以在客户端内必须要做处理才能实现平滑移动,项目里使用了MoveToward来让角色在每一个Update平滑地从当前位置向目标位置移动,但在跳跃等地方由于刚体速度不同会显得观感不佳,需要研究现有的MMO的实现方式,目前考虑的是同步位置时不同步y轴的坐标,而是发送一个跳跃协议给客户端,包括起跳点的信息,让客户端在移动到起跳点时自行跳跃,这样可以降低表现的延迟

动画同步

目前项目的动画同步是基于原坐标和接收坐标的变化和信息同步中的动画状态来改变的,效果不是很理想。我觉得动画还是应该脱离服务器,在本地进行处理效果会更顺滑。

项目架构

虽然参考的书籍中对项目的架构已经处理的较为明晰,但自己在修改完善的过程中发现整个项目还是有许多可以解耦合的地方,需要对设计模式进行系统地学习后再来考虑整个项目的重构