Unity3D网络游戏实战系列

搭建好了基础的网络模块后,我们就要来实现具体的网络协议了
长文警告!长文警告!

Enter协议

说明:当玩家进入游戏时,我们就触发Enter协议,创建一个角色给其他客户端,并且开始同步。

代码

GameManager.cs

public GameObject humanPrefab;//玩家角色预制体
public BaseHuman myHuman;//自身脚本
public Dictionary<string, BaseHuman> otherHumans;//存储其他角色

GameManager.cs-Start

private void Start()
{   
    //初始化
    otherHumans=new Dictionary<string, BaseHuman>();

    //绑定
    NetManager.AddListener("Enter",OnEnter);
    NetManager.AddListener("Move",OnMove);
    NetManager.AddListener("Leave",OnLeave);

    NetManager.Connect("127.0.0.1",2233);    //连接

    //添加一个角色
    var go = Instantiate(humanPrefab);
    var x = Random.Range(-5, 5);
    var z = Random.Range(-5, 5);
    go.transform.position=new Vector3(x,0,z);
    myHuman = go.AddComponent<CtrlHuman>();
    myHuman.desc = NetManager.GetDesc();

    //发送协议
    var pos = myHuman.transform.position;
    var eul = myHuman.transform.eulerAngles;
    var sendStr = "Enter|";
    sendStr += NetManager.GetDesc() + ",";
    sendStr += pos.x + ",";
    sendStr += pos.y + ",";
    sendStr += pos.z + ",";
    sendStr += eul.y;
    NetManager.Send(sendStr);
}

GameManager.cs-Update

private void Update()
{
    NetManager.Update();
}

GameManager.cs-Enter

        /// <summary>
        /// 进入
        /// </summary>
        /// <param name="msg"></param>
        void OnEnter(string msg)
        {

            Debug.Log("OnEnter:"+msg);
            //解析参数
            var data = msg.Split(',');
            var desc = data[0];
            var x = float.Parse(data[1]);
            var y = float.Parse(data[2]);
            var z = float.Parse(data[3]);
            var eulY = float.Parse(data[4]);

            //只对非自身角色进行处理
            if (desc==NetManager.GetDesc())
            {
                return;
            }

            //创建同步角色
            var go = Instantiate(humanPrefab);
            go.transform.position=new Vector3(x,eulY,z);
            var human = go.AddComponent<SyncHuman>();
            human.desc = desc;

           otherHumans.Add(desc,human);
        }

分析

设置要玩家角色的预制体,此预制体不要挂载任何BaseHuman的脚本,通过代码来动态挂载,在游戏启动Start时,创建一个随机位置的角色并发送Enter请求,服务器响应有广播Enter请求并加入列表,若存在其他客户端玩家时,在此客户端解析Enter协议,创建一个角色并挂载SyncHuman脚本即可。

运行截图


List协议

完成Enter协议后,发现在第一个开启的客户端已经能够看到后续连接的客户端,但是在第二个客户端缺看不到第一个客户端的角色。(后面开启的客户端看不到之前开启的客户端角色)
为了解决这个问题,书中引入了List协议,List协议其实就是一个当前玩家的在线列表,在玩家打开客户端时,发送List协议获取已经存在的客户端角色,并将数据同步到当前的客户端中就可以了

根据协议名自动调用服务端方法

在客户端我们也完成了类似的对应关系,现在我们在服务器来做相同的事,在书中作者使用了反射技术,通过MethodInfo类来实现通过方法名调用方法。
MethodInfo 步骤

  1. MethodInfo mi = typeof(类型).GetMethod(方法名); 获得方法信息
  2. object[] o = { entity, msgArgs }; 构建方法参数列表
  3. mi.Invoke(null, o); 使用Invoke()调用方法

在服务器端 ReadClientfd方法添加,并删除原先的遍历广播

//获取参数
string msgName = split[0];
string msgArgs = split[1];
string funName = "Msg" + msgName;
//通过反射动态调用方法
MethodInfo mi = typeof(MsgHandler).GetMethod(funName);
object[] o = { entity, msgArgs };
mi.Invoke(null, o);

完善服务器端

添加额外信息

class ClientEntity
{
   public Socket Socket { get; set;}
   public byte[] ReadBuff { get; set;}
   public int Hp { get; set; } = 100;
   public float X { get; set; }
   public float Y { get; set; }
   public float Z { get; set; }
   public float EulY { get; set; }
 }

添加MsgList方法

public static void MsgList(ClientEntity entity, string msgArgs)
{
    Console.WriteLine("MsgList:" + msgArgs);

    string sendStr = "List|";
    foreach (var item in Program.clientEntities.Values)
    {
        sendStr += item.Socket.RemoteEndPoint.ToString() + ",";
        sendStr += item.X + ",";
        sendStr += item.Y + ",";
        sendStr += item.Z + ",";
        sendStr += item.EulY + ",";
        sendStr += item.Hp + ",";
    }
    Program.Send(entity, sendStr);
}

客户端

添加NetManager.AddListener("List",OnList);
请求玩家列表 NetManager.Send("List|");

OnList

/// <summary>
/// 同步玩家列表
/// </summary>
/// <param name="msg"></param>
void OnList(string msg)
{
    Debug.Log("OnList:"+msg);
    //解析参数
    var split = msg.Split(',');
    var count = (split.Length - 1) / 6;
    for (int i = 0; i < count; i++)
    {
        var desc = split[i * 6 + 0];
        var x = float.Parse(split[i * 6 + 1]);
        var y = float.Parse(split[i * 6 + 2]);
        var z = float.Parse(split[i * 6 + 3]);
        var eulY = float.Parse(split[i * 6 + 4]);
        var hp = int.Parse(split[i * 6 + 5]);
        //
        if (desc==NetManager.GetDesc())
        {
            continue;
        }
        var go = Instantiate(humanPrefab);
        go.transform.position=new Vector3(x,y,z);
        go.transform.eulerAngles =new Vector3(0,eulY,0);
        BaseHuman human = go.AddComponent<SyncHuman>();
        human.desc = desc;
        otherHumans.Add(desc,human);
    }
}

List协议效果

可以看到,当我们有进万家加入游戏(启动新客户端时),其他客户端会自动增加一个sync的角色来同步。

吐槽一下,其实在服务端根本不用使用反射的,记得反射好像蛮费性能的。

Move协议

当玩家点击鼠标移动角色时,会给服务器发送消息,服务器负责将这些消息进行广播,其他客户端收到消息后,指引对应的syncHuman移动到指定位置。
Move协议示例:Move|127.0.0.1:2233,1,0,1其中,1,0,1就是目标点。

客户端

CtrlHuman-Update下添加

//发送协议
var sendStr = "Move|";
sendStr += NetManager.GetDesc() + ",";
sendStr += hit.point.x + ",";
sendStr += hit.point.y + ",";
sendStr += hit.point.z + ",";
NetManager.Send(sendStr);

GameManager下

    /// <summary>
    /// 移动
    /// </summary>
    /// <param name="msg"></param>
    void OnMove(string msg)
    {
        Debug.Log("OnMove:"+msg);
        //解析参数
        var split = msg.Split(',');
        var desc = split[0];
        var x = float.Parse(split[1]);
        var y = float.Parse(split[2]);
        var z = float.Parse(split[3]);
        //移动
        if (!otherHumans.ContainsKey(desc))
        {
            return;
        }

        BaseHuman human = otherHumans[desc];
        var targetPos=new Vector3(x,y,z);
        human.MoveTo(targetPos);
    }

服务器

MsgHandler下

public static void MsgMove(ClientEntity entity, 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]);

     entity.X = x;
     entity.Y = y;
     entity.Z = z;
     string sendStr = "Move|" + msgArgs;
     foreach (var item in Program.clientEntities.Values)
     {
         Program.Send(item, sendStr);
     }
}

运行效果图

在图中可以看到,虽然我们实现了移动位置上的同步,但是角色面向并没有同步,这是由我们的移动方式决定的。

Leave协议

Leave协议用于处理,玩家离开游戏时(断开连接),在此时服务器广播告诉其他客户端,客户端负责销毁sync游戏对象。

这部分就很简单了,在客户端部分:

/// <summary>
/// 离开
/// </summary>
/// <param name="msg"></param>
void OnLeave(string msg)
{
    Debug.Log("OnLeave:"+msg);
    var split = msg.Split(',');
    var desc = split[0];
    if (!otherHumans.ContainsKey(desc))
    {
        return;
    }

    BaseHuman human = otherHumans[desc];
    Destroy(human.gameObject);
    otherHumans.Remove(desc);

}

服务器部分在EnventHandler

public static void OnDisConnect(ClientEntity entity)
{
    Console.WriteLine("OnDisConnect");
    string desc = entity.Socket.RemoteEndPoint.ToString();
    string sendStr = "Leave|" + desc + ",";
    foreach (var item in Program.clientEntities.Values)
    {
        Program.Send(item, sendStr);
    }
}

leave协议效果

Attack协议

Attack协议体:
Attack|主机描述,攻击方向

协议效果

服务端处理

在服务端我们只需要将客户端发送的消息,广播到其他客户端即可,其代码如下:

public static void MsgAttack(ClientEntity entity, string msgArgs)
{
    string sendStr = "Attack|" + msgArgs;
    foreach (ClientEntity item in Program.clientEntities.Values)
    {
        Program.Send(item, sendStr);
    }
}

客户端处理

  1. 增加攻击动画(并添加一个IsAttacking参数控制播放)
  2. 修改BaseHuman
    设置攻击间隔为1.2s一次,并播放攻击动画。
public void Attack()
{
    IsAttacking = true;
    attackTime = Time.time;
    _animator.SetBool(Attacking,true);
}

void AttackUpdate()
{
    if (!IsAttacking)
    {
        return;
    }

    if (Time.time-attackTime<1.2f)
    {
        return;
    }

    IsAttacking = false;
    _animator.SetBool(Attacking,false);
}

然后在update中调用AttackUpdate做攻击计时。

  1. CtrlHuman 下设置鼠标响应事件,这里我们采用鼠标左键。
if (Input.GetMouseButtonDown(0))
{
    if (IsAttacking)
    {
        return;
    }

    if (IsMoving)
    {
        return;
    }

    //发送协议
    var sendStr = "Attack|";
    sendStr += NetManager.GetDesc()+",";
    sendStr += transform.eulerAngles.y + ",";
    NetManager.Send(sendStr);
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hit;
    Physics.Raycast(ray, out hit);
    transform.LookAt(hit.point);
    Attack();
}
  1. 在syncHuman中,同步角色攻击方向
public void SyncAttack(float eulY)
{
    transform.eulerAngles=new Vector3(0,eulY,0);
    Attack();
}

小结

在这一节中,我们完成了攻击协议的编写和实现,但是现在只是在客户端间同步了动画,这攻击并打不中人,客户端间并没有体现出交互性,我们在下一节的hit协议中继续完成。

Hit协议

** 2020年1月30日13:33:48 **

过年期间感冒了,并不是冠状病毒 ,不过还是挺严重的在家里躺了好几天;所以导致拖更了,今天感冒好很多了,又不能出门,继续学习吧。


hit协议是用于处理角色受伤的与Attack协议协调作用,其hit协议体为Hit|主机
服务器收到协议后,扣除被攻击者的角色的血量。

客户端

在书中,使用了射线法检测攻击碰撞,即朝着角色面对方向发射一条射线,若射线击中其他角色就说明攻击击中了其他角色。(这里还有许多别的方法~)

//攻击判定
var lineEnd = transform.position + 0.5f * Vector3.up;
var lineStart = lineEnd + 20 * transform.forward;
if (Physics.Linecast(lineStart,lineEnd,out hit))
{
    var hitObj = hit.collider.gameObject;
    if (hitObj==gameObject)
    {
        return;
    }

    SyncHuman syncHuman = hitObj.GetComponent<SyncHuman>();
    if (syncHuman==null)
    {
        return;
    }

    sendStr = "Hit|";
    sendStr += NetManager.GetDesc();
    sendStr += syncHuman.desc + ",";
    NetManager.Send(sendStr);
}

服务器端

在服务器端,收到hit协议后,找出受伤角色,扣血,血量小于0,角色死亡,播报Die协议。
(书中使用了25的固定扣血)

public static void MsgHit(ClientEntity entity, string msgArgs)
{
    //解析参数
    string[] split = msgArgs.Split(',');
    string attDesc = split[0];
    string hitDesc = split[1];
    //找出被攻击的角色
    ClientEntity hitCs = null;
    foreach (ClientEntity entity1 in Program.clientEntities.Values)
    {
        if (entity1.Socket.RemoteEndPoint.ToString().Equals(hitDesc))
        {
            hitCs = entity1;
            break;
        }
    }

    if (hitCs == null)
    {
        return;
    }

    //扣血
    hitCs.Hp -= 25;
    //死亡
    if (hitCs.Hp <= 0)
    {
        string sendStr = "Die|" + hitCs.Socket.RemoteEndPoint.ToString();
        foreach (var item in Program.clientEntities.Values)
        {
            Program.Send(item, sendStr);
        }
    }
}

Die协议

当角色生命值低于0时,死亡,进行死亡播报。
客户端收到这个Die协议后,删除对应的角色。

客户端

 void OnDie(string msg)
{
    Debug.Log("OnDie:"+msg);

    var split = msg.Split(',');
    var attDesc = split[0];
    var hitDesc = split[0];
    if (hitDesc==myHuman.desc)
    {
        Debug.Log("Game Over");
        myHuman.gameObject.SetActive(false);
        return;
    }

    if (!otherHumans.ContainsKey(hitDesc))
    {
        return;
    }
    SyncHuman human=otherHumans[hitDesc] as SyncHuman;
    human.gameObject.SetActive(false);
}

Hit&Die效果

总结

到此为至就是,书本第三章的内容的全部了,很勉强的实现了一个大乱斗游戏,在我自己的学习过程中我发现书本上的一些实现方法有些牵强,但不影响我们了解网络游戏的运作流程。
接下来的第四章和第五章都是纯粹的理论知识,我会一边更新一边抽空按我自己的想法完善大乱斗游戏,很多完善步骤比较琐碎,在具有一定完成度后我会再和大家分享,主要还是跟着书本完成余下内容为重点~