角色类
基类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);
}
移动同步:Move协议
客户端处理
修改Ctrlhuman类中控制角色移动的代码,当角色移动时,将目的地信息发送给服务端。
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);
//发送协议
string sendStr = "Move|";
sendStr += NetManager.GetDesc()+ ", ";
sendStr += hit.point.x + ", ";
sendStr += hit.point.y + ", ";
sendStr += hit.point.z + ", ";
NetManager.Send(sendStr);
}
}
}
修改Main的协议处理函数OnMove(记得添加对该协议的监听),解析协议参数,然后找到对应的同步角色,调用MoveTo方法让同步角色走到目的地。
void OnMove (string msgArgs) {
Debug.Log("OnMove" + 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]);
//移动
if(! otherHumans.ContainsKey(desc))
return;
BaseHuman h = otherHumans[desc];
Vector3 targetPos = new Vector3(x, y, z);
h.MoveTo(targetPos);
}
服务端处理
服务端收到Move协议后,解析参数,记录坐标信息,然后广播Move协议
public static void MsgMove(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]);
//赋值
c.x = x;
c.y = y;
c.z = z;
//广播
string sendStr = "Move|" + msgArgs;
foreach (ClientState cs in MainClass.clients.Values){
MainClass.Send(cs, sendStr);
}
}
玩家离开:Leave协议
当某个客户端掉线,服务端会广播Leave协议,客户端收到后删除对应的角色
客户端处理
当客户端收到Leave协议后,调用监听函数OnLeave,删除对应的同步角色,同时把它从同步角色列表otherHumans中删掉
void OnLeave (string msgArgs) {
Debug.Log("OnLeave" + msgArgs);
//解析参数
string[] split = msgArgs.Split(', ');
string desc = split[0];
//删除
if(! otherHumans.ContainsKey(desc))
return;
BaseHuman h = otherHumans[desc];
Destroy(h.gameObject);
otherHumans.Remove(desc);
}
服务端处理
当客户端掉线时,会触发服务端的Disconnect事件,只要在Disconnect事件的处理函数OnDisconnect中编写发送Leave协议的代码即可。
using System;
public class EventHandler
{
public static void OnDisconnect(ClientState c){
string desc = c.socket.RemoteEndPoint.ToString();
string sendStr = "Leave|" + desc + ", ";
foreach (ClientState cs in MainClass.clients.Values){
MainClass.Send(cs, sendStr);
}
}
}
攻击动作:Attack协议
第一个参数为角色描述,第二个参数为攻击的方向。在CtrlHuman发起攻击动作后,将Attack协议发送给服务端
客户端处理
if(Input.GetMouseButtonDown(1)){
if(isAttacking) return;
if(isMoving) return;
……
//发送协议
string sendStr = "Attack|";
sendStr += NetManager.GetDesc()+ ", ";
sendStr += transform.eulerAngles.y + ", ";
NetManager.Send(sendStr);
}
当客户端接收到服务端转发的Attack协议时,它会解析协议参数,然后调用对应同步角色的SyncAttack方法。修改的Main代码如下
NetManager.AddListener("Attack", OnAttack);
void OnAttack (string msgArgs) {
Debug.Log("OnAttack" + msgArgs);
//解析参数
string[] split = msgArgs.Split(', ');
string desc = split[0];
float eulY = float.Parse(split[1]);
//攻击动作
if(! otherHumans.ContainsKey(desc))
return;
SyncHuman h = (SyncHuman)otherHumans[desc];
h.SyncAttack(eulY);
}
服务端处理
服务端只需转发Attack协议
public static void MsgAttack(ClientState c, string msgArgs){
//广播
string sendStr = "Attack|" + msgArgs;
foreach (ClientState cs in MainClass.clients.Values){
MainClass.Send(cs, sendStr);
}
}
攻击伤害:Hit协议
客户端处理
修改CtrlHuman,添加攻击判断的代码,如下所示。先做一条有向线段,如果线段碰到了带有SyncHuman组件的角色,表示角色被击中,客户端发送Hit协议通知服务端谁被击中了
//攻击
if(Input.GetMouseButtonDown(1)){
……
//发送协议
……
//攻击判定
Vector3 lineEnd = transform.position + 0.5f*Vector3.up;
Vector3 lineStart = lineEnd + 20*transform.forward;
if(Physics.Linecast(lineStart, lineEnd, out hit)){
GameObject hitObj = hit.collider.gameObject;
if(hitObj == gameObject)
return;
SyncHuman h = hitObj.GetComponent<SyncHuman>();
if(h == null)
return;
sendStr = "Hit|";
sendStr += NetManager.GetDesc()+ ", ";
sendStr += h.desc + ", ";
NetManager.Send(sendStr);
}
}
服务端处理
当服务端收到Hit协议后,它会找出受到攻击的角色,然后扣血(此处固定扣除25滴血)。当被攻击的角色血量小于0,代表角色死亡,服务端会广播Die协议,通知客户端删除该角色。
public static void MsgHit(ClientState c, string msgArgs){
//解析参数
string[] split = msgArgs.Split(', ');
string attDesc = split[0];
string hitDesc = split[1];
//找出被攻击的角色
ClientState hitCS = null;
foreach (ClientState cs in MainClass.clients.Values){
if(cs.socket.RemoteEndPoint.ToString() == hitDesc)
hitCS = cs;
}
if(hitCS == null)
return;
//扣血
hitCS.hp -= 25;
//死亡
if(hitCS.hp <= 0){
string sendStr = "Die|" + hitCS.socket.RemoteEndPoint.ToString();
foreach (ClientState cs in MainClass.clients.Values){
MainClass.Send(cs, sendStr);
}
}
}
角色死亡:Die协议
客户端处理函数如下,如果是玩家操控的角色死亡,打印出“GameOver”,如果是其他玩家死亡,删掉他[通过SetActive(false)实现
void OnDie (string msgArgs) {
Debug.Log("OnDie" + msgArgs);
//解析参数
string[] split = msgArgs.Split(', ');
string attDesc = split[0];
string hitDesc = split[0];
//自己死了
if(hitDesc == myHuman.desc){
Debug.Log("Game Over");
return;
}
//死了
if(! otherHumans.ContainsKey(hitDesc))
return;
SyncHuman h = (SyncHuman)otherHumans[hitDesc];
h.gameObject.SetActive(false);
}