「游戏开发实战」Unity使用Socket通信实现简单的多人聊天室

目录

文章目录

二、简单的Socket通信:多人聊天室三、拓展:Mirror Networking1、局域网多人联机Demo的救星:Mirror4、Mirror 案例测试:多人坦克对战5、Mirror 案例讲解:多人坦克对战5.1、NetworkManager物体5.1.1、NetworkManager组件5.1.2、NetworkManagerHUD组件5.1.3、KcpTransport组件5.2、地面(带导航功能)5.2.2、导航烘焙:Navigation5.3、坦克生成点:NewworkStartPosition5.4.2、NavMeshAgent组件5.4.5、NetworkTransform组件5.4.6、NetworkIdentity组件5.4.7、NetworkBehaviour组件: Tank5.7、坦克脚本:Tank.cs5.8、Transform的网络同步:NetworkTransform.cs5.9、炮弹脚本:Projectile.cs

一、前言

嗨,大家好,我是新发。

事情是这样的,上次有同学问我能不能出一期 网络 相关的教程,

然而我眼花看错了,看成了 网格 ,我还专门写了一篇文章: 《【游戏开发进阶】Unity网格探险之旅(Mesh | 动态合集 | 骨骼动画 | 蒙皮 )》

直到有同学在评论里提醒我,真是尴尬…

嘛,没事,今天就补上,写一篇 网络 相关文章。

我准备做个例子,使用 .Net 原生的 Socket 模块来实现简单的多人聊天室功能。

话不多说,我们开始吧~

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

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

为了演示,我就用 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)

关于 socket 的 python 函数原型可以使用 help(socket) 查看,

第一个参数是 socket domains (通信协议族),有两种类型: AF_UNIX 、 AF_INET ,它们的区别:

通信协议族

说明

AF_UNIX

本机通信;另,它只能够用于单一的 Unix 系统进程间通信,不能在 Windows 系统中使用

AF_INET

TCP/IP 通信

第二个参数是 socket type (套接字类型),有 SOCKET_STREAM 、 SOCK_DGRAM 、 SOCK_RAW三种,

套接字类型

说明

SOCKET_STREAM

流式套接字,基于 TCP 通信,数据有保障(即能保证数据正确传送到对方),多用于资料(如文件)传送

SOCK_DGRAM

数据报套接字,基于 UDP 通信,数据是有保障的 , 主要用于在网络上发广播信息

SOCK_RAW

原始套接字,普通的套接字无法处理 ICMP 、 IGMP 等网络报文,而 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 完整代码如下:

作者:林新发,博客: 功能:简单的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封装 * 作者:林新发 博客: */ 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交互 * 作者:林新发 博客: */ 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 { // 连接 var address = unameInput.text.Split(:); 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 Mode 为 Windowed ,设置窗口默认宽高为 640 x 360 ,

执行打包,

打包成功,

4、运行测试

先使用 python 运行服务端,

开启多个客户端,分别登录服务端,用户名分别是 皮皮猫 和 林新发 吧~

服务端的输出:

开始聊天,

服务端的输出:

运行一切正常,完美。

5、工程源码

上面这个简单聊天室工程源码已上传到 CODE CHINA ,感兴趣的同学可自行下载下来进行学习,

工程地址:

注:我使用的 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

Mirror 是 Unity 的高级网络 API ,支持不同的低级传输( UDP 、 TCP 、 KCP 等等)。

使用 Mirror ,客户端、服务端是在同一个工程中的,这就是为什么它叫 Mirror 。 也就是说它没有一个独立的服务端,而是由一台客户端作为 Host ,它既是客户端又是服务端,其他客户端连接这台 Host 客户端 。画成图是这样子:

Mirror 是开源的,它的社区很活跃,配套的文档也很详尽,大家可以从官网进行学习,不过是全英文的。

Mirror官网:

https://mirror-networking.com/

Mirror GitHub:

https://github.com/vis2k/Mirror

Mirror Asset Store:

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 地址:

将 Mirror 插件添加到自己的账号中,然后回到 Unity ,在 Package Manager 中就可以下载了,

下载下来导入 Unity 中,

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

Mirror 中给我们提供了几个例子,

我以多人坦克对战为例,双击 Assets / Mirror / Examples / Tanks / Scenes/ Scene 进入场景,

运行后左上角出现三个按钮,如下

要开启两个客户端,为了方便演示,我先打出个 exe ,

打包成功后,运行两个客户端,其中一个作为 Host ,另一个客户端连接 Host ,运行效果如下:

可以看到我们对坦克的控制是实时同步到另一个端的。

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

下面,我以多人坦克对战案例为例,给大家讲下制作过程。

5.1、NetworkManager物体

先创建一个空物体,重命名为 NetworkManager ,挂以下三个脚本:

NetworkManager 、 NetworkManagerHUD 、 KcpTransport ,

5.1.1、NetworkManager组件

我们先看下官方手册:

意思就是, NetworkManager 是管理多个客户端连接的组件。它是多人联机游戏的核心控制组件。

一个场景中只能有一个激活的 NetworkManager (它是单例模式的)。

连接的服务端 IP 地址在 NetworkManager 中进行设置, Max Connections 是最大连接数。

(注意:任何一个客户端都可以同时是一个服务端)

5.1.2、NetworkManagerHUD组件

NetworkManagerHUD 组件是下面这个 GUI 的逻辑,通过它我们可以方便地进行测试。

5.1.3、KcpTransport组件

Mirror 帮我们封装了各种不同等级的传输协议(各种 Transport 组件),常用的是 KcpTransport 和 TelepathyTransport 。

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组件

我们先看下官方手册:

意思就是说, NetworkTransform 组件会通过网络自动同步 position 、 rotation 和 scale。

带 NetworkTransform 组件的物体必须也带 NetworkIdentity 组件。

我们可以设置 Positon 、 Rotation 、 Scale 同步的敏感度,

为了让同步有一个平滑效果(不会一卡一卡的),我们可以勾选平滑差值,

5.4.6、NetworkIdentity组件

我们先看下官方手册:

意思就是说, NetworkIdentity 组件提供了游戏物体在网络中的唯一标识( ID )。

游戏运行过程中,我们在 Inspector 视图中预览到 NetworkIdentity 的信息。

5.4.7、NetworkBehaviour组件: Tank

Tank 脚本是坦克行为脚本,它继承 NetworkBehaviour 。

这里只讲 NetworkBehaviour 组件, Tank 具体代码后面再讲~

我们先看看官方手册:

意思就是说, NetworkBehaviour 脚本处理具有 NetworkIdentity 组件的游戏对象, NetworkBehaviour 的子类中可以处理高级 API 功能,例如 Commands 、 ClientRpcs 、 SyncEvents 、SyncVars 。

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 下载:

我们看到反编译出来的 Tank 的 CmdFire 函数的代码已经完全变了另外一个逻辑了,它发送了一个 “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 dont 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 dont want a warning if OnTriggerEnter is // called on the client [ServerCallback] void OnTriggerEnter(Collider co) { NetworkServer.Destroy(gameObject); } } }

四、完毕

原文 /article/details/118888064