文章目录

一、前言

嗨,大家好,我是新发。
事情是这样的,上次有同学问我能不能出一期 网络 相关的教程,
在这里插入图片描述
然而我眼花看错了,看成了 网格,我还专门写了一篇文章:《【游戏开发进阶】Unity网格探险之旅(Mesh | 动态合批 | 骨骼动画 | 蒙皮 )》
直到有同学在评论里提醒我,真是尴尬…
在这里插入图片描述
嘛,没事,今天就补上,写一篇 网络 相关文章。

我准备做个例子,使用.Net原生的Socket模块来实现简单的多人聊天室功能。
话不多说,我们开始吧~

二、简单的Socket通信:多人聊天室

Unity中我们要实现网络通信,可以使用.NetSocket模块来实现。

为了演示,我就用python写个简单的服务端,用Unity作为客户端。
先画个 流程图

服务端(python)流程图:
在这里插入图片描述
客户端(Unity)流程图:
在这里插入图片描述

1、服务端:python代码

新建一个python脚本:game_server.py,如下
在这里插入图片描述

1.1、import socket

因为我们要使用socket,所以先引入socket模块:

import socket 
1.2、构造socket对象
g_socket_server = None
g_socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

关于socketpython函数原型可以使用help(socket)查看,
在这里插入图片描述
第一个参数是socket domains通信协议族),有两种类型:AF_UNIXAF_INET,它们的区别:

通信协议族说明
AF_UNIX本机通信;另,它只能够用于单一的Unix系统进程间通信,不能在Windows系统中使用
AF_INETTCP/IP通信

第二个参数是socket type(套接字类型),有SOCKET_STREAMSOCK_DGRAMSOCK_RAW三种,

套接字类型说明
SOCKET_STREAM流式套接字,基于TCP通信,数据有保障(即能保证数据正确传送到对方),多用于资料(如文件)传送
SOCK_DGRAM数据报套接字,基于UDP通信,数据是无保障的 , 主要用于在网络上发广播信息
SOCK_RAW原始套接字,普通的套接字无法处理ICMPIGMP等网络报文,而SOCK_RAW可以;SOCK_RAW也可以处理特殊的IPv4报文;此外,利用原始套接字,可以通过IP_HDRINCL套接字选项由用户构造IP头
1.3、绑定/监听端口
ADDRESS = ('127.0.0.1', 8712)
g_socket_server.bind(ADDRESS)
g_socket_server.listen(5)
1.3、监听客户端连接
client, info = g_socket_server.accept()
1.4、接收客户端socket消息
data = client.recv(1024)
msg = data.decode(encoding='utf8')

使用json对消息字段进行解析:

import json

jd = json.loads(jsonstr)
protocol = jd['protocol']
uname = jd['uname']
msg = jd['msg']
1.5、多线程

由于监听客户端(socket.accept)和接收消息(socket.recv)都是 阻塞 的,为了不阻塞主线程,我们使用 子线程 来处理。
创建不带参数的线程:

thread = Thread(target=thread_func)
thread.start()

def thread_func():
	pass

创建带参数的线程:

thread = Thread(target=thread_func, args=(p1, p2, p3))
thread.start()

def thread_func(p1, p2, p3):
	pass
1.6、完整代码:game_server.py

最终,game_server.py完整代码如下:

'''
作者:林新发,博客:https://blog.csdn.net/linxinfa
功能:简单的Socket通信,聊天室服务端
python版本:3.6.4
'''
import socket  # 导入 socket 模块
from threading import Thread
import time
import json

ADDRESS = ('127.0.0.1', 8712)  # 绑定地址
g_socket_server = None  # 负责监听的socket
g_conn_pool = {}  # 连接池

def accept_client():
    global g_socket_server
    g_socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  
    g_socket_server.bind(ADDRESS)
    g_socket_server.listen(5)  # 最大等待数(有很多人理解为最大连接数,其实是错误的)
    print("server start,wait for client connecting...")
    '''
    接收新连接
    '''
    while True:
        client, info = g_socket_server.accept()  # 阻塞,等待客户端连接
        # 给每个客户端创建一个独立的线程进行管理
        thread = Thread(target=message_handle, args=(client, info))
        thread.setDaemon(True)
        thread.start()
 
 
def message_handle(client, info):
    '''
    消息处理
    '''
    handle_id = info[1]
    # 缓存客户端socket对象
    g_conn_pool[handle_id] = client
    while True:
        try:
            data = client.recv(1024)
            jsonstr = data.decode(encoding='utf8')
            jd = json.loads(jsonstr)
            protocol = jd['protocol']
            uname = jd['uname']
            if 'login' == protocol:
                print('on client login, ' + uname)
                 # 转发给所有客户端
                for u in g_conn_pool:
                    g_conn_pool[u].sendall((uname + " 进入了房间").encode(encoding='utf8'))
            elif 'chat' == protocol:
                # 收到客户端聊天消息
                print(uname + ":" + jd['msg'])
                # 转发给所有客户端
                for key in g_conn_pool:
                    g_conn_pool[key].sendall((uname + " : " + jd['msg']).encode(encoding='utf8'))
        except Exception as e:
            remove_client(handle_id)
            break

def remove_client(handle_id):
    client = g_conn_pool[handle_id]
    if None != client:
        client.close()
        g_conn_pool.pop(handle_id)
        print("client offline: " + str(handle_id))


if __name__ == '__main__':
    # 新开一个线程,用于接收新连接
    thread = Thread(target=accept_client)
    thread.setDaemon(True)
    thread.start()
    # 主线程逻辑
    while True:
        time.sleep(0.1) 
2、客户端:Unity
2.1、创建工程,搭建场景

新建一个Unity工程,
在这里插入图片描述
使用UGUI简单搭建一下界面,如下
在这里插入图片描述
养成好习惯,界面保存为预设:TestPanel.prefab
在这里插入图片描述

2.2、Socket封装:ClientSocket.cs

我们先封装一个ClientSocket.cs,实现Socket的创建、连接和收发消息等功能。

2.2.1、构造Socket对象
// using System.Net.Sockets;

Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
2.2.2、连接服务器
socket.Connect(host, port);
2.2.3、断开连接
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
2.2.4、发送消息
// byte[] bytes 你的消息的字节数组
NetworkStream netstream = new NetworkStream(socket);
netstream.Write(bytes, 0, bytes.Length);
2.2.5、接收服务端消息
// 回调函数对象
AsyncCallback recvCb = new AsyncCallback(RecvCallBack);
// 数据缓存
byte[] recvBuff = new byte[0x4000];
// 消息队列
Queue<string> msgQueue = new Queue<string>();

// 每帧调用此方法
socket.BeginReceive(recvBuff, 0, recvBuff.Length, SocketFlags.None, recvCb, this);

// 接收消息回调函数
private void RecvCallBack(IAsyncResult ar)
{
	var len = socket.EndReceive(ar);
    byte[] msg = new byte[len];
    Array.Copy(m_recvBuff, msg, len);
    var msgStr = System.Text.Encoding.UTF8.GetString(msg);
    // 将消息塞入队列中
    msgQueue.Enqueue(msgStr);
}

// 从消息队列中取出消息(供外部调用)
public string GetMsgFromQueue()
{
    if (msgQueue.Count > 0)
        return msgQueue.Dequeue();
    return null;
}
2.2.6、完整代码:ClientSocket.cs

最终,ClientSocket.cs完整代码如下:

/*
 * Socket封装
 * 作者:林新发 博客:https://blog.csdn.net/linxinfa
*/

using System;
using System.Net.Sockets;
using UnityEngine;
using System.Collections.Generic;

public class ClientSocket
{
    private Socket init()
    {
        Socket clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        // 接收的消息数据包大小限制为 0x4000 byte, 即16KB
        m_recvBuff = new byte[0x4000];
        m_recvCb = new AsyncCallback(RecvCallBack);
        return clientSocket;
    }

    /// <summary>
    /// 连接服务器
    /// </summary>
    /// <param name="host">ip地址</param>
    /// <param name="port">端口号</param>
    public void Connect(string host, int port)
    {
        if (m_socket == null)
            m_socket = init();
        try
        {
            Debug.Log("connect: " + host + ":" + port);
            m_socket.SendTimeout = 3;
            m_socket.Connect(host, port);
            connected = true;

        }
        catch (Exception ex)
        {
            Debug.LogError(ex);
        }
    }

    /// <summary>
    /// 发送消息
    /// </summary>
    public void SendData(byte[] bytes)
    {
        NetworkStream netstream = new NetworkStream(m_socket);
        netstream.Write(bytes, 0, bytes.Length);
    }

    /// <summary>
    /// 尝试接收消息(每帧调用)
    /// </summary>
    public void BeginReceive()
    {
        m_socket.BeginReceive(m_recvBuff, 0, m_recvBuff.Length, SocketFlags.None, m_recvCb, this);
    }

    /// <summary>
    /// 当收到服务器的消息时会回调这个函数
    /// </summary>
    private void RecvCallBack(IAsyncResult ar)
    {
        var len = m_socket.EndReceive(ar);
        byte[] msg = new byte[len];
        Array.Copy(m_recvBuff, msg, len);
        var msgStr = System.Text.Encoding.UTF8.GetString(msg);
        // 将消息塞入队列中
        m_msgQueue.Enqueue(msgStr);
        // 将buffer清零
        for (int i = 0; i < m_recvBuff.Length; ++i)
        {
            m_recvBuff[i] = 0;
        }
    }

    /// <summary>
    /// 从消息队列中取出消息
    /// </summary>
    /// <returns></returns>
    public string GetMsgFromQueue()
    {
        if (m_msgQueue.Count > 0)
            return m_msgQueue.Dequeue();
        return null;
    }

    /// <summary>
    /// 关闭Socket
    /// </summary>
    public void CloseSocket()
    {
        Debug.Log("close socket");
        try
        {
            m_socket.Shutdown(SocketShutdown.Both);
            m_socket.Close();
        }
        catch(Exception e)
        {
            //Debug.LogError(e);
        }
        finally
        {
            m_socket = null;
            connected = false;
        }
    }


    public bool connected = false;

    private byte[] m_recvBuff;
    private AsyncCallback m_recvCb;
    private Queue<string> m_msgQueue = new Queue<string>();
    private Socket m_socket;
}
2.3、UI交互:TestPanel.cs

然后再创建一个脚本:TestPanel.cs,用于实现UI部分的交互逻辑。

2.3.1、定义变量

先定义一些变量:

private const string IP = "127.0.0.1";
private const int PORT = 8712;

// 用户名输入
public InputField unameInput;
// 消息输入
public InputField msgInput;
// 登录按钮
public Button loginBtn;
// 发送按钮
public Button sendBtn;
// 连接状态文本
public Text stateTxt;
// 连接按钮文本
public Text connectBtnText;
// 聊天室聊天文本
public Text chatMsgTxt;
// 封装的ClientSocket对象
private ClientSocket clientSocket = new ClientSocket();
2.3.2、登录服务端
// 连接
clientSocket.Connect(IP, PORT);
stateTxt.text = clientSocket.connected ? "已连接" : "未连接";
connectBtnText.text = clientSocket.connected ? "断开" : "连接";
if (clientSocket.connected)
    unameInput.enabled = false;
// 登录
Send("login");
2.3.3、断开连接
clientSocket.CloseSocket();
stateTxt.text = "已断开";
connectBtnText.text = "连接";
unameInput.enabled = true;
2.3.4、发送消息

这里用了一个迷你版的json库:JSONConvert,源码可以参见我之前写的这篇文章:《用C#实现一个迷你json库,无需引入dll(可直接放到Unity中使用)》

private void Send(string protocol, string msg = "")
{
    JSONObject jsonObj = new JSONObject();
    jsonObj["protocol"] = protocol;
    jsonObj["uname"] = unameInput.text;
    jsonObj["msg"] = msg;
    // JSONObject转string
    string jsonStr = JSONConvert.SerializeObject(jsonObj);
    // string转byte[]
    byte[] data = System.Text.Encoding.UTF8.GetBytes(jsonStr);
    // 发送消息给服务端
    clientSocket.SendData(data);
}
2.3.5、接收消息
private void Update()
{
    if (clientSocket.connected)
    {
        clientSocket.BeginReceive();
    }
    var msg = clientSocket.GetMsgFromQueue();
	if (!string.IsNullOrEmpty(msg))
    {
    	// 显示到聊天室文本中
        chatMsgTxt.text += msg + "\n";
        Debug.Log("RecvCallBack: " + msg);
    }
}    
2.3.6、完整代码:TestPanel.cs

最终,TestPanel.cs完整代码如下:

/*
 * 聊天室客户端 UI交互
 * 作者:林新发 博客:https://blog.csdn.net/linxinfa
*/


using UnityEngine;
using UnityEngine.UI;

public class TestPanel : MonoBehaviour
{
    private const string IP = "127.0.0.1";
    private const int PORT = 8712;

    // 用户名输入
    public InputField unameInput;
    // 消息输入
    public InputField msgInput;
    // 登录按钮
    public Button loginBtn;
    // 发送按钮
    public Button sendBtn;
    // 连接状态文本
    public Text stateTxt;
    // 连接按钮文本
    public Text connectBtnText;
    // 聊天室聊天文本
    public Text chatMsgTxt;
    // 封装的ClientSocket对象
    private ClientSocket clientSocket = new ClientSocket();

    private ClientSocket clientSocket = new ClientSocket();

    void Start()
    {
        chatMsgTxt.text = "";

        loginBtn.onClick.AddListener(() =>
        {
            if (clientSocket.connected)
            {
                // 断开
                clientSocket.CloseSocket();
                stateTxt.text = "已断开";
                connectBtnText.text = "连接";
                unameInput.enabled = true;
            }
            else
            {
                // 连接
                clientSocket.Connect(IP, PORT);
                stateTxt.text = clientSocket.connected ? "已连接" : "未连接";
                connectBtnText.text = clientSocket.connected ? "断开" : "连接";
                if (clientSocket.connected)
                    unameInput.enabled = false;
                // 登录
                Send("login");
            }
        });

        sendBtn.onClick.AddListener(() =>
        {
            Send("chat", msgInput.text);
        });
    }

    private void Update()
    {
        if (clientSocket.connected)
        {
            clientSocket.BeginReceive();
        }
        var msg = clientSocket.GetMsgFromQueue();
        if (!string.IsNullOrEmpty(msg))
        {
            chatMsgTxt.SetAllDirty();
            chatMsgTxt.text += msg + "\n";
            
            Debug.Log("RecvCallBack: " + msg);
        }
    }

    private void Send(string protocol, string msg = "")
    {
        JSONObject jsonObj = new JSONObject();
        jsonObj["protocol"] = protocol;
        jsonObj["uname"] = unameInput.text;
        jsonObj["msg"] = msg;
        // JSONObject转string
        string jsonStr = JSONConvert.SerializeObject(jsonObj);
        // string转byte[]
        byte[] data = System.Text.Encoding.UTF8.GetBytes(jsonStr);
        // 发送消息给服务端
        clientSocket.SendData(data);
    }

    private void OnApplicationQuit()
    {
        if (clientSocket.connected)
        {
            clientSocket.CloseSocket();
        }
    }
}
2.4、挂脚本,赋值成员对象

TestPanel界面挂上TestPanel.cs脚本,赋值成员对象,如下
在这里插入图片描述

3、打包客户端

因为我们要测试多个客户端连接一个服务端,为了方便测试,我们打个Windows平台的exe
Build Settings中添加要打包的场景,选择PC, Mac & Linux Standalone平台,
在这里插入图片描述
我们不想全屏显示客户端,在Player Settings中,找到Resolution and Presentation,设置Fullscreen ModeWindowed,设置窗口默认宽高为640 x 360
在这里插入图片描述
执行打包,
在这里插入图片描述
打包成功,
在这里插入图片描述

4、运行测试

先使用python运行服务端,
在这里插入图片描述
开启多个客户端,分别登录服务端,用户名分别是皮皮猫林新发吧~
在这里插入图片描述
服务端的输出:
在这里插入图片描述
开始聊天,
在这里插入图片描述
服务端的输出:
在这里插入图片描述
运行一切正常,完美。

5、工程源码

上面这个简单聊天室工程源码已上传到CODE CHINA,感兴趣的同学可自行下载下来进行学习,
工程地址:https://codechina.csdn.net/linxinfa/UnitySocketDemo
注:我使用的Unity版本:Unity 2021.1.9f1c1 (64-bit)

另外关于CODE CHINA的使用教程我之前也写了一篇文章,感兴趣的同学可以看看:
《CODE.CHINA使用教程,创建项目仓库并上传代码(git)》

在这里插入图片描述

三、拓展:Mirror Networking

1、局域网多人联机Demo的救星:Mirror

上面的简单聊天室功能,我们是做了一个独立的服务端负责消息的转发,聊天本身的逻辑非常简单,我们把大部分工作花在了维护Socket上,要解决多线程问题,要解决连接断开,要解决消息的序列化和反序列化等等。

有些同学做了一个单机版的小Demo,想改成局域网多人联机版,要处理好多复杂的同步问题,比如物理碰撞、状态同步等等,这个对于Unity萌新来说,不大友好。
在这里插入图片描述
有没有什么好用的网络库可以让开发更高效呢?有,那就是:Mirror

注:在Unity 5.1 ~ Unity2018中你可以使用UNet(全称Unity Networking),到Unity 2019之后UNet就被废弃了,Mirror就是来替代UNet的。你在网上搜到的Unity Netwoking的教程就是UNet,它已经过时了,不要再使用UNet了!

2、关于Mirror

在这里插入图片描述
MirrorUnity的高级网络 API,支持不同的低级传输(UDPTCPKCP等等)。
使用 Mirror,客户端、服务端是在同一个工程中的,这就是为什么它叫Mirror也就是说它没有一个独立的服务端,而是由一台客户端作为Host,它既是客户端又是服务端,其他客户端连接这台Host客户端。画成图是这样子:
在这里插入图片描述
Mirror是开源的,它的社区很活跃,配套的文档也很详尽,大家可以从官网进行学习,不过是全英文的。

Mirror官网:
https://mirror-networking.com/

Mirror GitHub:
https://github.com/vis2k/Mirror

Mirror Asset Store:
https://assetstore.unity.com/packages/tools/network/mirror-129321

Mirror 官方文档:
https://mirror-networking.gitbook.io/docs/

Mirror API手册:
https://mirror-networking.com/docs/api/Mirror.html

Unity 与 Mirror的兼容:
Mirror最适合Unity 2019 LTS
Mirror通常也适用于所有较新的LTS版本(即2020 LTS)。

3、Mirror插件下载

建议从Asset Store上下载Mirror版本,因为GitHub的版本不一定稳定,
Asset Store地址:
https://assetstore.unity.com/packages/tools/network/mirror-129321
在这里插入图片描述
Mirror插件添加到自己的账号中,然后回到Unity,在Package Manager中就可以下载了,
在这里插入图片描述
下载下来导入Unity中,
在这里插入图片描述

4、Mirror 案例测试:多人坦克对战

Mirror中给我们提供了几个例子,
在这里插入图片描述
我以多人坦克对战为例,双击Assets / Mirror / Examples / Tanks / Scenes/ Scene进入场景,
在这里插入图片描述
运行后左上角出现三个按钮,如下
在这里插入图片描述
要开启两个客户端,为了方便演示,我先打出个exe
在这里插入图片描述
打包成功后,运行两个客户端,其中一个作为Host,另一个客户端连接Host,运行效果如下:
在这里插入图片描述
可以看到我们对坦克的控制是实时同步到另一个端的。

5、Mirror 案例讲解:多人坦克对战

下面,我以多人坦克对战案例为例,给大家讲下制作过程。
为了让大家有个直观理解,我画个图:
请添加图片描述

5.1、NetworkManager物体

先创建一个空物体,重命名为NetworkManager,挂以下三个脚本:
NetworkManagerNetworkManagerHUDKcpTransport
在这里插入图片描述

5.1.1、NetworkManager组件

我们先看下官方手册:https://mirror-networking.gitbook.io/docs/components/network-manager
在这里插入图片描述
意思就是,NetworkManager是管理多个客户端连接的组件。它是多人联机游戏的核心控制组件。
一个场景中只能有一个激活的NetworkManager(它是单例模式的)。
连接的服务端IP地址在NetworkManager中进行设置,Max Connections是最大连接数。
(注意:任何一个客户端都可以同时是一个服务端)
在这里插入图片描述

5.1.2、NetworkManagerHUD组件

NetworkManagerHUD组件是下面这个GUI的逻辑,通过它我们可以方便地进行测试。
在这里插入图片描述

5.1.3、KcpTransport组件

Mirror帮我们封装了各种不同等级的传输协议(各种Transport组件),常用的是KcpTransportTelepathyTransport
KcpTransport是使用可靠UDP协议,TelepathyTransport是使用TCP协议。
在这里插入图片描述

Transport组件中可以设置端口号、最大延迟等等参数:
在这里插入图片描述

5.2、地面(带导航功能)
5.2.1、创建Plane

创建一个Plane作为地面地面,重命名为Ground,给它赋值一个材质球,
在这里插入图片描述
效果如下:
在这里插入图片描述

5.2.2、导航烘焙:Navigation

接下来我们对地面执行导航系统烘焙,这样方便限制坦克的活动范围。
我们将地面设置为静态对象,
在这里插入图片描述
点击菜单Window / AI / Navigation,打开Navigation(导航/寻路系统)视图,
在这里插入图片描述
Navigation视图中点击Bake标签按钮,点击Bake按钮,对地面进行导航烘焙,
在这里插入图片描述
看到蓝色网格则说明烘焙成功,
在这里插入图片描述

5.3、坦克生成点:NewworkStartPosition

创建四个空物体,重名命为Spawn,挂上NewworkStartPosition

注:如果不创建生成点,则坦克默认在(0, 0, 0)坐标点出生成。

在这里插入图片描述
调节四个生成点的位置,分散在地面的四个角落,如下
在这里插入图片描述

5.4、坦克身上的组件
5.4.1、坦克预设

准备一个坦克模型,
在这里插入图片描述
包装成坦克预设:Tank.prefab
在这里插入图片描述
坦克预设上挂以下脚本:
在这里插入图片描述

5.4.2、NavMeshAgent组件

NavMeshAgent组件是导航代理组件,挂上这个组件就具备了导航功能;
关于导航系统的使用,可以参见我之前写的文章:《Unity游戏开发——新发教你做游戏(五):导航系统Navigation》

《[原创] 用Unity等比例制作广州地铁,广州加油,早日战胜疫情(Unity | 地铁地图 | 第三人称视角)》

5.4.4、Animator组件

动画控制器,用于控制坦克的行驶、开炮等动画。
在这里插入图片描述
关于Animator相关的教程,我之前写过两篇文章:《Unity动画状态机Animator使用》
《Animator控制角色动画播放》,感兴趣的同学可以看看。

5.4.5、NetworkTransform组件

我们先看下官方手册:https://mirror-networking.gitbook.io/docs/components/network-transform
在这里插入图片描述
意思就是说,NetworkTransform组件会通过网络自动同步positionrotationscale
NetworkTransform组件的物体必须也带NetworkIdentity组件。
在这里插入图片描述
我们可以设置PositonRotationScale同步的敏感度,
在这里插入图片描述
为了让同步有一个平滑效果(不会一卡一卡的),我们可以勾选平滑差值,
在这里插入图片描述

5.4.6、NetworkIdentity组件

我们先看下官方手册:https://mirror-networking.gitbook.io/docs/components/network-identity
在这里插入图片描述
意思就是说,NetworkIdentity组件提供了游戏物体在网络中的唯一标识(ID)。
游戏运行过程中,我们在Inspector视图中预览到NetworkIdentity的信息。
在这里插入图片描述

5.4.7、NetworkBehaviour组件: Tank

Tank脚本是坦克行为脚本,它继承NetworkBehaviour
这里只讲NetworkBehaviour组件,Tank具体代码后面再讲~
我们先看看官方手册:https://mirror-networking.gitbook.io/docs/guides/networkbehaviour
在这里插入图片描述
意思就是说,NetworkBehaviour脚本处理具有NetworkIdentity组件的游戏对象,NetworkBehaviour的子类中可以处理高级API功能,例如CommandsClientRpc'sSyncEventsSyncVars

NetworkBehaviour组件具有以下功能:
Synchronized variables:同步变量
Network callbacks:网络回调
Server and client functions:服务端和客户端函数
Sending commands:发送命令
Client RPC calls:客户端远程过程调用
Networked events:网络事件
在这里插入图片描述

NetworkBehaviour提供了一些 网络回调
OnStartServer回调
这个回调函数只在服务端调用,当在服务端生成一个游戏对象,或者服务端启动时被回调。
OnStopServer回调
这个回调函数只在服务端调用,当在服务端销毁一个游戏对象,或者服务端停止时被回调。
OnStartClient回调
这个回调函数只在客户端调用,当客户端生成一个游戏对象,或者客户端连接到服务端时被回调。
OnStopClient回调
这个回调函数只在客户端调用,当服务端销毁一个游戏对象时被回调。
OnStartLocalPlayer回调
这个回调函数只在客户端调用,当客户端生成一个玩家对象时被回调。
OnStartAuthority回调
这个回调函数只在客户端调用,当游戏对象拿到控制权时。
OnStopAuthority回调
这个回调函数只在客户端调用,当游戏对象失去控制权时。

标记服务端函数或客户端函数:
NetworkBehaviour中,我们可以使用[Server][ServerCallback][Client][ClientCallback]这些注解对函数进行标注。
[Server][ServerCallback]表示函数为服务端函数,只在服务端执行;
[Client][ClientCallback]表示为客户端函数,只在客户端执行。

Command 命令:
使用[Command]注解对函数进行标记,表示这个函数是由客户端调用,由服务端来执行。具体原理我下文会通过反编译dll来解释。
[Command]标记的函数约定以Cmd开头。

Client RPC 客户端远程过程调用:
使用[ClientRpc]注解对函数进行标记,表示这个函数是由服务端调用,由客户端来执行。具体原理我下文会通过反编译dll来解释。
[ClientRpc]标记的函数约定以Rpc开头。

Networked Events 网络事件(观察者模式):
类似于Client RPC调用,不同之处是它触发的是事件。
使用[SyncEvent]对事件进行标记。被[SyncEvent]标记的事件变量必须以Event开头,例EventTakeDamage。例子可以参见官方手册:https://mirror-networking.gitbook.io/docs/guides/synchronization/syncevent

Mirror提供的函数注解如下(部分注解我们上面已做了介绍),具体的注解可以参见Mirror官方手册:https://mirror-networking.gitbook.io/docs/guides/attributes
在这里插入图片描述

5.5、赋值PlayerPrefab

选中NetworkManager物体,给NetworkManager组件赋值PlayerPrefab为坦克预设,
在这里插入图片描述

5.6、炮弹预设

准备一个炮弹模型,
在这里插入图片描述
包装成炮弹预设:Projectile.prefab
在这里插入图片描述
炮弹预设上挂以下脚本:
在这里插入图片描述
NetworkIdentity:因为炮弹也是一个网络对象,所以它需要NetworkIdentity组件;
炮弹的Transform信息不使用NetworkTransform进行同步,而是通过Rigibody刚体组件的力来使炮弹飞行,所以只需要同步一下力即可,在Projectile脚本中实现炮弹的逻辑。

5.7、坦克脚本:Tank.cs

网络对象的行为脚本需要继承NetworkBehaviour,所以Tank类需要继承NetworkBehaviour

public class Tank : NetworkBehaviour
{
}

Tank脚本要实现的逻辑是坦克的 移动 / 旋转开炮
其中移动的同步会自动通过NetworkTransform进行同步,所以我们只需对本地坦克进行控制即可,

// Tank.cs

void Update()
{
    // isLocalPlayer是父类NetworkBehaviour的属性,用于判断当前NetworkBehaviour对象是否为本地对象;
    if (!isLocalPlayer) return;

    // 旋转
    float horizontal = Input.GetAxis("Horizontal");
    transform.Rotate(0, horizontal * rotationSpeed * Time.deltaTime, 0);

    // 移动
    float vertical = Input.GetAxis("Vertical");
    Vector3 forward = transform.TransformDirection(Vector3.forward);
    agent.velocity = forward * Mathf.Max(vertical, 0) * agent.speed;
    animator.SetBool("Moving", agent.velocity != Vector3.zero);
	
	// ...
}

开炮需要由服务端来执行,

// Tank.cs

void Update()
{
	// ...
	
	if (Input.GetKeyDown(shootKey))
    {
        CmdFire();
    }
}

// this is called on the server
[Command]
void CmdFire()
{
    GameObject projectile = Instantiate(projectilePrefab, projectileMount.position, transform.rotation);
    NetworkServer.Spawn(projectile);
    RpcOnFire();
}

// this is called on the tank that fired for all observers
[ClientRpc]
void RpcOnFire()
{
    animator.SetTrigger("Shoot");
}

这里用到了两个注解[Command][ClientRpc],我们上面讲到它是NetworkBehaviour组件的函数注解。
上面我们讲到[Command],它是由客户端来调用,由服务端来执行。
这个怎么理解呢?
事实上Mirror实现了一些编译器hack,会在编译阶段动态生成特定的代码(也就是把你的代码编译为别的代码)。
这样讲好像不好理解,没事,我们反编译一下C#dll就知道了。
进入工程路径 / Library / ScriptAssemblies这个目录,Mirror的案例代码是编译在Mirror.Examples.dll中,
在这里插入图片描述
我们使用ILSpy.exe对它进行反编译,

注:ILSpy反编译工具可以从GitHub下载:https://github.com/icsharpcode/ILSpy

我们看到反编译出来的TankCmdFire函数的代码已经完全变了另外一个逻辑了,它发送了一个“CmdFire”消息给服务端,
在这里插入图片描述
开炮流程变成了下面这样子:
在这里插入图片描述
同理,[ClientRpc]是由服务端调用,由客户端执行。
我们的代码:
在这里插入图片描述
编译后:
在这里插入图片描述
在这里插入图片描述
完整的Tank.cs代码入下:

using UnityEngine;
using UnityEngine.AI;

namespace Mirror.Examples.Tanks
{
    public class Tank : NetworkBehaviour
    {
        [Header("Components")]
        public NavMeshAgent agent;
        public Animator animator;

        [Header("Movement")]
        public float rotationSpeed = 100;

        [Header("Firing")]
        public KeyCode shootKey = KeyCode.Space;
        public GameObject projectilePrefab;
        public Transform projectileMount;

        void Update()
        {
            // movement for local player
            if (!isLocalPlayer) return;

            // rotate
            float horizontal = Input.GetAxis("Horizontal");
            transform.Rotate(0, horizontal * rotationSpeed * Time.deltaTime, 0);

            // move
            float vertical = Input.GetAxis("Vertical");
            Vector3 forward = transform.TransformDirection(Vector3.forward);
            agent.velocity = forward * Mathf.Max(vertical, 0) * agent.speed;
            animator.SetBool("Moving", agent.velocity != Vector3.zero);

            // shoot
            if (Input.GetKeyDown(shootKey))
            {
                CmdFire();
            }
        }

        // this is called on the server
        [Command]
        void CmdFire()
        {
            GameObject projectile = Instantiate(projectilePrefab, projectileMount.position, transform.rotation);
            NetworkServer.Spawn(projectile);
            RpcOnFire();
        }

        // this is called on the tank that fired for all observers
        [ClientRpc]
        void RpcOnFire()
        {
            animator.SetTrigger("Shoot");
        }
    }
}
5.8、Transform的网络同步:NetworkTransform.cs

坦克身上挂NetworkTransform组件,坦克Transform的同步由它来负责。

5.9、炮弹脚本:Projectile.cs

炮弹也是一个网络对象,它的行为脚本也必须继承NetworkBehaviour

// Projectile.cs
public class Projectile : NetworkBehaviour
{
}

炮弹预设实例化后,需要给Rigibody一个力,从而让炮弹向前飞行,

// Projectile.cs
void Start()
{
   	rigidBody.AddForce(transform.forward * force);
}

炮弹需要有一个生命周期控制,超过5秒自动销毁,执行NetworkServer.Destroy(gameObject)来销毁对象,

// Projectile.cs
public override void OnStartServer()
{
    Invoke(nameof(DestroySelf), destroyAfter);
}

[Server]
void DestroySelf()
{
    NetworkServer.Destroy(gameObject);
}

我们看到这里有一个[Server]注解,它表示只有服务端可以调用此函数。

我们反编译可以看到它自动加了一个NetworkServer.active判断,
在这里插入图片描述
我们再看[ServerCallback],它与[Server]一样,只能在服务端调用,只是没有Warning输出而已,如下
在这里插入图片描述
编译后:
在这里插入图片描述
完整的Projectile.cs代码如下:

using UnityEngine;

namespace Mirror.Examples.Tanks
{
    public class Projectile : NetworkBehaviour
    {
        public float destroyAfter = 5;
        public Rigidbody rigidBody;
        public float force = 1000;

        public override void OnStartServer()
        {
            Invoke(nameof(DestroySelf), destroyAfter);
        }

        // set velocity for server and client. this way we don't have to sync the
        // position, because both the server and the client simulate it.
        void Start()
        {
            rigidBody.AddForce(transform.forward * force);
        }

        // destroy for everyone on the server
        [Server]
        void DestroySelf()
        {
            NetworkServer.Destroy(gameObject);
        }

        // ServerCallback because we don't want a warning if OnTriggerEnter is
        // called on the client
        [ServerCallback]
        void OnTriggerEnter(Collider co)
        {
            NetworkServer.Destroy(gameObject);
        }
    }
}

四、完毕

好了,就先写这么多吧~

最后再补充一个,我之前写了一篇关于UnityWebRequest的文章,它也与网络通信相关,大家感兴趣的也可以看下:《长江后浪推前浪,UnityWebRequest替代WWW》

我是新发,喜欢我的可以点赞、关注、收藏,如果有什么技术上的疑问,欢迎留言或私信,拜拜~

Logo

致力于链接即构和开发者,提供实时互动和元宇宙领域的前沿洞察、技术分享和丰富的开发者活动,共建实时互动世界。

更多推荐