角色类
基类Base Human是基础的角色类,它处理“操控角色”和“同步角色”的一些共有功能;CtrlHuman类代表“操控角色”,它在BaseHuman类的基础上处理鼠标操控功能;SyncHuman类是“同步角色”类,它也继承自BaseHuman,并处理网络同步(如果有必要)。
BaseHuman
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BaseHuman : MonoBehaviour {
//是否正在移动
protected bool isMoving = false;
//移动目标点
private Vector3 targetPosition;
//移动速度
public float speed = 1.2f;
//动画组件
private Animator animator;
//描述
public string desc = "";
//移动到某处
public void MoveTo(Vector3 pos){
targetPosition = pos;
isMoving = true;
animator.SetBool("isMoving", true);
}
//移动Update
public void MoveUpdate(){
if(isMoving == false) {
return;
}
Vector3 pos = transform.position;
transform.position = Vector3.MoveTowards(pos, targetPosition, speed*Time.
deltaTime);
transform.LookAt(targetPosition);
if(Vector3.Distance(pos, targetPosition) < 0.05f){
isMoving = false;
animator.SetBool("isMoving", false);
}
}
// Use this for initialization
protected void Start () {
animator = GetComponent<Animator>();
}
// Update is called once per frame
protected void Update () {
MoveUpdate();
}
}
CtrlHuman
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CtrlHuman : BaseHuman
{
new void Start()
{
base.Start();
}
// Update is called once per frame
new void Update()
{
base.Update();
if(Input.GetMouseButtonDown(0)) {
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
Physics.Raycast(ray,out hit);
if(hit.collider.tag == "Terrain") {
MoveTo(hit.point);
}
}
}
}
如何使用网络模块
在实际的网络游戏开发中,网络模块往往是作为一个底层模块用的,它应该和具体的游戏逻辑分开,而不应该把处理逻辑的代码写到 ReceiveCallback 里面去。因为ReceiveCallback应当只处理网络数据,不应该去处理游戏功能
一个可行的做法是,给网络管理类添加回调方法,当收到某种消息时就自动调用某个函数,这样便能够将游戏逻辑和底层模块分开。制作网络管理类前,需要先了解委托、协议和消息队列这三个概念。
通信协议
通信协议是通信双方对数据传送控制的一种约定,通信双方必须共同遵守,方能“知道对方在说什么”和“让对方听懂我的话”。
使用一种最简单的字符串协议来实现。协议格式如下所示,消息名和消息体用“|”隔开,消息体中各个参数用“, ”隔开。
消息名|参数1, 参数2, 参数3, ...
Move|127.0.0.1:1234, 10, 0, 8,
处理数据:
string str = "Move|127.0.0.1:1234, 10, 0,8, ";
string[] args = str.Split('|');
string msgName = args[0]; //协议名:Move
string msgBody = args[1]; //协议体:127.0.0.1:1234, 10, 0,8,
string[] bodyArgs = msgBody.Split(', ');
string desc = bodyArgs [0]; //玩家描述:127.0.0.1:1234
float x = float.Parse(bodyArgs [1]); //x坐标:10
float y = float.Parse(bodyArgs [2]); //y坐标:0
float z = float.Parse(bodyArgs [3]); //z坐标:8
消息队列
多线程消息处理虽然效率较高,但非主线程不能设置Unity3D组件,而且容易造成各种莫名其妙的混乱。由于单线程消息处理足以满足游戏客户端的需要,因此大部分游戏会使用消息队列让主线程去处理异步Socket接收到的消息。
C#的异步通信由线程池实现,不同的BeginReceive不一定在同一线程中执行。创建一个消息列表,每当收到消息便在列表末端添加数据,这个列表由主线程读取,它可以作为主线程和异步接收线程之间的桥梁。由于MonoBehaviour的Update方法在主线程中执行,可让Update方法每次从消息列表中读取几条信息并处理,处理后便在消息列表中删除它们
NetManager类
网络模块中最核心的地方是一个称为NetManager的静态类,这个类对外提供了三个最主要的接口。
- Connect方法,调用后发起连接;
- AddListener方法,消息监听。其他模块可以通过AddListener设置某个消息名对应的处理方法,当网络模块接收到这类消息时,就会回调处理方法;
- Send方法,发送消息给服务端。
无论内部实现有多么复杂,网络模块对外的接口只有图片展示的这几个:
对内部而言,NetManager使用了异步Socket接收消息,每次接收到一条消息后,NetManager会把消息存入消息队列中。NetManager有一个供外部调用的Update方法,每当调用它时就会处理消息队列里的第一条消息,然后根据协议名将消息分发给对应的回调函数
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using UnityEngine.UI;
using System;
public static class NetManager {
//定义套接字
static Socket socket;
//接收缓冲区
static byte[] readBuff = new byte[1024];
//委托类型
public delegate void MsgListener(String str);
//监听列表
private static Dictionary<string, MsgListener> listeners =
new Dictionary<string, MsgListener>();
//消息列表
static List<String> msgList = new List<string>();
//添加监听
public static void AddListener(string msgName, MsgListener listener){
listeners[msgName] = listener;
}
//获取描述
public static string GetDesc(){
if(socket == null) return "";
if(! socket.Connected) return "";
return socket.LocalEndPoint.ToString();
}
//连接
public static void Connect(string ip, int port)
{
//Socket
socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
//Connect(用同步方式简化代码)
socket.Connect(ip, port);
//BeginReceive
socket.BeginReceive( readBuff, 0, 1024, 0,
ReceiveCallback, socket);
}
//Receive回调
private static void ReceiveCallback(IAsyncResult ar){
try {
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndReceive(ar);
string recvStr =
System.Text.Encoding.Default.GetString(readBuff, 0, count);
msgList.Add(recvStr);
socket.BeginReceive( readBuff, 0, 1024, 0,
ReceiveCallback, socket);
}
catch (SocketException ex){
Debug.Log("Socket Receive fail" + ex.ToString());
}
}
//发送
public static void Send(string sendStr)
{
if(socket == null) return;
if(! socket.Connected)return;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
}
//Update
public static void Update(){
if(msgList.Count <= 0)
return;
String msgStr = msgList[0];
msgList.RemoveAt(0);
string[] split = msgStr.Split('|');
string msgName = split[0];
string msgArgs = split[1];
//监听回调;
if(listeners.ContainsKey(msgName)){
listeners[msgName](msgArgs);
}
}
}
漏洞
上述代码没有处理粘包分包、线程冲突等问题
进入游戏:Enter协议
当玩家打开游戏,客户端程序会生成一个操控角色(CtrlHuman),并把它放到场景中的一个随机位置。然后发送一条Enter协议给服务端,包含了对玩家的描述、位置等信息。服务端将Enter协议广播出去,其他客户端收到Enter协议后,创建一个同步角色(SyncHuman)
创建角色
void Start()
{
//网络模块
NetManager.AddListener("Enter", OnEnter);
NetManager.AddListener("Move", OnMove);
NetManager.AddListener("Leave", OnLeave);
NetManager.Connect("127.0.0.1", 8888);
//添加一个新角色
GameObject obj = (GameObject)Instantiate(humanPrefab);
float x = Random.Range(-5, 5);
float z = Random.Range(-5, 5);
obj.transform.position = new Vector3(x, 0, z);
myHuman = obj.AddComponent<CtrlHuman>();
myHuman.desc = NetManager.GetDesc();
//发送协议
Vector3 pos = myHuman.transform.position;
Vector3 eul = myHuman.transform.eulerAngles;
string sendStr = "Enter|";
sendStr += NetManager.GetDesc() + ",";
sendStr += pos.x + ",";
sendStr += pos.y + ",";
sendStr += pos.z + ",";
sendStr += eul.y;
NetManager.Send(sendStr);
}
服务端如何处理消息
反射机制
如果网络模块能在解析协议名后,自动调用名为“Msg+协议名”的方法,那便大功告成,而这其中,C#的反射机制是实现该功能的关键
修改服务端的代码,完成消息处理函数的自动调用
using System.Reflection;
using System.Linq;
//读取Clientfd
public static bool ReadClientfd(Socket clientfd){
ClientState state = clients[clientfd];
//接收消息
……
//客户端关闭(count==0)
……
//消息处理
string recvStr =
System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
string[] split = recvStr.Split('|');
Console.WriteLine("Recv" + recvStr);
string msgName = split[0];
string msgArgs = split[1];
string funName = "Msg" + msgName;
MethodInfo mi = typeof(MsgHandler).GetMethod(funName);
object[] o = {state, msgArgs};
mi.Invoke(null, o);
return true;
}
MethodInfo类对象mi包含它所指代的方法的所有信息,通过这个类可以得到方法的名称、参数、返回值等,并且可以调用它。假设所有的消息处理方法都定义在MsgHandler类中,且都是静态方法,通过typeof(MsgHandler).GetMethod(funName)便能够获取MsgHandler类中名为funName的静态方法。
mi.Invoke(null, o)代表调用mi所包含的方法。第一个参数null代表this指针,由于消息处理方法都是静态方法,因此此处要填null。第二个参数o代表的是参数列表。这里定义的消息处理函数都有两个参数,第一个参数是客户端状态state,第二个参数是消息的内容msgArgs。
消息处理函数
MsgHandler.cs的文件,用它来定义存放所有消息处理函数的Msg-Handler类
using System;
using System.Collections.Generic;
class MsgHandler
{
public static void MsgEnter(ClientState c, string msgArgs){
Console.WriteLine("MsgEnter" + msgArgs);
}
public static void MsgList(ClientState c, string msgArgs){
Console.WriteLine ("MsgList" + msgArgs);
}
}
时间处理
using System;
public class EventHandler
{
public static void OnDisconnect(ClientState c){
Console.WriteLine ("OnDisconnect");
}
}
修改服务端接收消息的代码ReadClientfd,当玩家下线时,调用EventHandler.OnDis-connect,代码如下所示。同理可以在Accept处添加接受客户端连接的事件。
//读取Clientfd
public static bool ReadClientfd(Socket clientfd){
ClientState state = clients[clientfd];
//接收
int count = 0;
try{
count = clientfd.Receive(state.readBuff);
}catch(SocketException ex){
MethodInfo mei = typeof(EventHandler).GetMethod("OnDisconnect");
object[] ob = {state};
mei.Invoke(null, ob);
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Receive SocketException" + ex.ToString());
return false;
}
//客户端关闭
if(count <= 0){
MethodInfo mei = typeof(EventHandler).GetMethod("OnDisconnect");
object[] ob = {state};
mei.Invoke(null, ob);
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Close");
return false;
}
//消息处理
……
}
玩家数据
让后进来的玩家也可以接收到其他已经存在的玩家信息,当玩家进入场景时,向服务端请求List协议,服务端收到后,将场景中的人物信息返回给客户端。要达成这个功能,服务端必须要记录各个玩家的坐标信息。
public class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
public int hp = -100;
public float x = 0;
public float y = 0;
public float z = 0;
public float eulY = 0;
}
处理Enter协议
服务端接收到Enter协议(以及后续的Move协议)后,需要把玩家的坐标信息记录下来,再广播出去。可通过修改处理消息的MsgHandler.MsgEnter方法来实现。它先解析客户端发来的协议参数,然后给代表该客户端的ClientState赋值,最后将协议广播给所有的客户端。代码如下:
public static void MsgEnter(ClientState c, string msgArgs)
{
//解析参数
string[] split = msgArgs.Split(', ');
string desc = split[0];
float x = float.Parse(split[1]);
float y = float.Parse(split[2]);
float z = float.Parse(split[3]);
float eulY = float.Parse(split[4]);
//赋值
c.hp = 100;
c.x = x;
c.y = y;
c.z = z;
c.eulY = eulY;
//广播
string sendStr = "Enter|" + msgArgs;
foreach (ClientState cs in MainClass.clients.Values){
MainClass.Send(cs, sendStr);
}
}
玩家列表:List协议
当玩家进入场景后,调用NetManager.Send发送List协议。服务端收到后回应各个客户端的信息
public class Main : MonoBehaviour {
……
void Start () {
//网络模块
NetManager.AddListener("Enter", OnEnter);
NetManager.AddListener("List", OnList);
NetManager.AddListener("Move", OnMove);
NetManager.AddListener("Leave", OnLeave);
NetManager.Connect("127.0.0.1", 8888);
//添加角色,发送Enter协议
……
//请求玩家列表
NetManager.Send("List|");
}
void OnList (string msgArgs) {
Debug.Log("OnList" + msgArgs);
//解析参数
string[] split = msgArgs.Split(', ');
int count = (split.Length-1)/6;
for(int i = 0; i < count; i++){
string desc = split[i*6+0];
float x = float.Parse(split[i*6+1]);
float y = float.Parse(split[i*6+2]);
float z = float.Parse(split[i*6+3]);
float eulY = float.Parse(split[i*6+4]);
int hp = int.Parse(split[i*6+5]);
//是自己
if(desc == NetManager.GetDesc())
continue;
//添加一个角色
GameObject obj = (GameObject)Instantiate(humanPrefab);
obj.transform.position = new Vector3(x, y, z);
obj.transform.eulerAngles = new Vector3(0, eulY, 0);
BaseHuman h = obj.AddComponent<SyncHuman>();
h.desc = desc;
otherHumans.Add(desc, h);
}
}
……
}
服务端处理
public static void MsgList(ClientState c, string msgArgs){
string sendStr = "List|";
foreach (ClientState cs in MainClass.clients.Values){
sendStr+=cs.socket.RemoteEndPoint.ToString() + ", ";
sendStr+=cs.x.ToString() + ", ";
sendStr+=cs.y.ToString() + ", ";
sendStr+=cs.z.ToString() + ", ";
sendStr+=cs.eulY.ToString() + ", ";
sendStr+=cs.hp.ToString() + ", ";
}
MainClass.Send(c, sendStr);
}