嗨,大家好,我是新發。
事情是這樣的,上次有同學問我能不能出一期 網絡 相關的教程,
然而我眼花看錯了,看成了 網格 ,我還專門寫了一篇文章: 《【游戲開發進階】Unity網格探險之旅(Mesh | 動態合集 | 骨骼動畫 | 蒙皮 )》
直到有同學在評論里提醒我,真是尷尬…
嘛,沒事,今天就補上,寫一篇 網絡 相關文章。
我準備做個例子,使用 .Net 原生的 Socket 模塊來實現簡單的多人聊天室功能。
話不多說,我們開始吧~
Unity 中我們要實現網絡通信,可以使用 .Net 的 Socket 模塊來實現。
為了演示,我就用 python 寫個簡單的服務端,用 Unity 作為客戶端。
先畫個 流程圖 。
服務端( python )流程圖:
客戶端( Unity )流程圖:
新建一個 python 腳本: game_server.py ,如下
因為我們要使用 socket ,所以先引入 socket 模塊:
import 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頭 |
ADDRESS=('127.0.0.1', 8712)
g_socket_server.bind(ADDRESS)
g_socket_server.listen(5)
client, info=g_socket_server.accept()
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']
由于監聽客戶端( 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
最終, 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)
新建一個 Unity 工程,
使用 UGUI 簡單搭建一下界面,如下
養成好習慣,界面保存為預設: TestPanel.prefab ,
我們先封裝一個 ClientSocket.cs ,實現 Socket 的創建、連接和收發消息等功能。
// using System.Net.Sockets;
Socket socket=new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(host, port);
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket=null;
// byte[] bytes 你的消息的字節數組
NetworkStream netstream=new NetworkStream(socket);
netstream.Write(bytes, 0, bytes.Length);
// 回調函數對象
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;
}
最終, 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;
}
然后再創建一個腳本: TestPanel.cs ,用于實現 UI 部分的交互邏輯。
先定義一些變量:
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();
// 連接
clientSocket.Connect(IP, PORT);
stateTxt.text=clientSocket.connected ? "已連接" : "未連接";
connectBtnText.text=clientSocket.connected ? "斷開" : "連接";
if (clientSocket.connected)
unameInput.enabled=false;
// 登錄
Send("login");
clientSocket.CloseSocket();
stateTxt.text="已斷開";
connectBtnText.text="連接";
unameInput.enabled=true;
這里用了一個迷你版的 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);
}
private void Update()
{
if (clientSocket.connected)
{
clientSocket.BeginReceive();
}
var msg=clientSocket.GetMsgFromQueue();
if (!string.IsNullOrEmpty(msg))
{
// 顯示到聊天室文本中
chatMsgTxt.text +=msg + "\n";
Debug.Log("RecvCallBack: " + msg);
}
}
最終, 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
{
// 連接
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();
}
}
}
給 TestPanel 界面掛上 TestPanel.cs 腳本,賦值成員對象,如下
因為我們要測試多個客戶端連接一個服務端,為了方便測試,我們打個 Windows 平臺的 exe。
在 Build Settings 中添加要打包的場景,選擇 PC, Mac & Linux Standalone 平臺,
我們不想全屏顯示客戶端,在 Player Settings 中,找到 Resolution and Presentation ,設置 Fullscreen Mode 為 Windowed ,設置窗口默認寬高為 640 x 360 ,
執行打包,
打包成功,
先使用 python 運行服務端,
開啟多個客戶端,分別登錄服務端,用戶名分別是 皮皮貓 和 林新發 吧~
服務端的輸出:
開始聊天,
服務端的輸出:
運行一切正常,完美。
上面這個簡單聊天室工程源碼已上傳到 CODE CHINA ,感興趣的同學可自行下載下來進行學習,
工程地址: https://codechina.csdn.net/linxinfa/UnitySocketDemo
注:我使用的 Unity 版本: Unity 2021.1.9f1c1 (64-bit) 。
另外關于 CODE CHINA 的使用教程我之前也寫了一篇文章,感興趣的同學可以看看:
《CODE.CHINA使用教程,創建項目倉庫并上傳代碼(git)》
上面的簡單聊天室功能,我們是做了一個獨立的服務端負責消息的轉發,聊天本身的邏輯非常簡單,我們把大部分工作花在了維護 Socket 上,要解決多線程問題,要解決連接斷開,要解決消息的序列化和反序列化等等。
有些同學做了一個單機版的小 Demo ,想改成局域網多人聯機版,要處理好多復雜的同步問題,比如物理碰撞、狀態同步等等,這個對于 Unity 萌新來說,不大友好。
有沒有什么好用的網絡庫可以讓開發更高效呢?有,那就是: Mirror !
注:在 Unity 5.1 ~ Unity2018 中你可以使用 UNet (全稱 Unity Networking ),到 Unity 2019 之后 UNet 就被廢棄了, Mirror 就是來替代 UNet 的。你在網上搜到的 Unity Netwoking的教程就是 UNet ,它已經過時了,不要再使用 UNet 了!
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:
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 )。
建議從 Asset Store 上下載 Mirror 版本,因為 GitHub 的版本不一定穩定,
Asset Store 地址:
https://assetstore.unity.com/packages/tools/network/mirror-129321
將 Mirror 插件添加到自己的賬號中,然后回到 Unity ,在 Package Manager 中就可以下載了,
下載下來導入 Unity 中,
Mirror 中給我們提供了幾個例子,
我以多人坦克對戰為例,雙擊 Assets / Mirror / Examples / Tanks / Scenes/ Scene 進入場景,
運行后左上角出現三個按鈕,如下
要開啟兩個客戶端,為了方便演示,我先打出個 exe ,
打包成功后,運行兩個客戶端,其中一個作為 Host ,另一個客戶端連接 Host ,運行效果如下:
可以看到我們對坦克的控制是實時同步到另一個端的。
下面,我以多人坦克對戰案例為例,給大家講下制作過程。
先創建一個空物體,重命名為 NetworkManager ,掛以下三個腳本:
NetworkManager 、 NetworkManagerHUD 、 KcpTransport ,
我們先看下官方手冊: https://mirror-networking.gitbook.io/docs/components/network-manager
意思就是, NetworkManager 是管理多個客戶端連接的組件。它是多人聯機游戲的核心控制組件。
一個場景中只能有一個激活的 NetworkManager (它是單例模式的)。
連接的服務端 IP 地址在 NetworkManager 中進行設置, Max Connections 是最大連接數。
(注意:任何一個客戶端都可以同時是一個服務端)
NetworkManagerHUD 組件是下面這個 GUI 的邏輯,通過它我們可以方便地進行測試。
Mirror 幫我們封裝了各種不同等級的傳輸協議(各種 Transport 組件),常用的是 KcpTransport 和 TelepathyTransport 。
KcpTransport 是使用可靠 UDP 協議, TelepathyTransport 是使用 TCP 協議。
Transport 組件中可以設置端口號、最大延遲等等參數:
創建一個 Plane 作為地面地面,重命名為 Ground ,給它賦值一個材質球,
效果如下:
接下來我們對地面執行導航系統烘焙,這樣方便限制坦克的活動范圍。
我們將地面設置為靜態對象,
點擊菜單 Window / AI / Navigation ,打開 Navigation (導航/尋路系統)視圖,
在 Navigation 視圖中點擊 Bake 標簽按鈕,點擊 Bake 按鈕,對地面進行導航烘焙,
看到藍色網格則說明烘焙成功,
創建四個空物體,重命名為 Spawn ,掛上 NewworkStartPosition ,
注:如果不創建生成點,則坦克默認在 (0, 0, 0) 坐標點出生成。
調節四個生成點的位置,分散在地面的四個角落,如下
準備一個坦克模型,
包裝成坦克預設: Tank.prefab ,
坦克預設上掛以下腳本:
NavMeshAgent 組件是導航代理組件,掛上這個組件就具備了導航功能;
關于導航系統的使用,可以參見我之前寫的文章: 《Unity游戲開發——新發教你做游戲(五):導航系統Navigation》
《[原創] 用Unity等比例制作廣州地鐵,廣州加油,早日戰勝疫情(Unity | 地鐵地圖 | 第三人稱視角)》
動畫控制器,用于控制坦克的行駛、開炮等動畫。
關于 Animator 相關的教程,我之前寫過兩篇文章: 《Unity動畫狀態機Animator使用》 、
《Animator控制角色動畫播放》 ,感興趣的同學可以看看。
我們先看下官方手冊: https://mirror-networking.gitbook.io/docs/components/network-transform
意思就是說, NetworkTransform 組件會通過網絡自動同步 position 、 rotation 和 scale。
帶 NetworkTransform 組件的物體必須也帶 NetworkIdentity 組件。
我們可以設置 Positon 、 Rotation 、 Scale 同步的敏感度,
為了讓同步有一個平滑效果(不會一卡一卡的),我們可以勾選平滑差值,
我們先看下官方手冊: https://mirror-networking.gitbook.io/docs/components/network-identity
意思就是說, NetworkIdentity 組件提供了游戲物體在網絡中的唯一標識( ID )。
游戲運行過程中,我們在 Inspector 視圖中預覽到 NetworkIdentity 的信息。
Tank 腳本是坦克行為腳本,它繼承 NetworkBehaviour 。
這里只講 NetworkBehaviour 組件, Tank 具體代碼后面再講~
我們先看看官方手冊: https://mirror-networking.gitbook.io/docs/guides/networkbehaviour
意思就是說, NetworkBehaviour 腳本處理具有 NetworkIdentity 組件的游戲對象, NetworkBehaviour 的子類中可以處理高級 API 功能,例如 Commands 、 ClientRpc's 、 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
選中 NetworkManager 物體,給 NetworkManager 組件賦值 PlayerPrefab 為坦克預設,
準備一個炮彈模型,
包裝成炮彈預設: Projectile.prefab ,
炮彈預設上掛以下腳本:
NetworkIdentity :因為炮彈也是一個網絡對象,所以它需要 NetworkIdentity 組件;
炮彈的 Transform 信息不使用 NetworkTransform 進行同步,而是通過 Rigibody 剛體組件的力來使炮彈飛行,所以只需要同步一下力即可,在 Projectile 腳本中實現炮彈的邏輯。
網絡對象的行為腳本需要繼承 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
我們看到反編譯出來的 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");
}
}
}
坦克身上掛 NetworkTransform 組件,坦克 Transform 的同步由它來負責。
炮彈也是一個網絡對象,它的行為腳本也必須繼承 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);
}
}
}
原文 https://blog.csdn.net/linxinfa/article/details/118888064
注:以下文章是我收錄兩年前記錄的CSDN博客。
一、前言
1、有些人其實會覺得Unity3D用到的.NET是2.0的,其實不然;Unity3D有用到.NET3.5,為什么說Unity用到的是3.5呢,從一個很常用卻很重要的一個命名空間說起,他就是System.Linq命名空間,這個命名空間是.NET3.5重要的一次改革和核心部分(本命名空間與該文章并沒有什么很大的聯系,只是提下而已)。至于為什么顯示成2.0我也不是很清楚,可能只支持部分3.5吧,不過對我們來說關系并不是很大。只要支持Linq就可以了。
2、前提工作:虛擬串口和Unity3D切換成.NET。
2.1 虛擬串口的創建,可以從網上下載一個創建虛擬串口的軟件,比如“VSPD虛擬串口”,還是挺好用的,不過因為我做Unity3D的虛擬串口工作,所以根據VSPD專門寫了一個創建虛擬串口的程序(暫時不提供)。在創建虛擬串口的時候注意一個很重要的問題,就是盡量創建串口號大于10的,比如COM10、COM11甚至夸張點COM100等,為什么要這樣子,后面我會介紹Unity3D打開串口時,串口號大于10時,打開串口方式與.NET打開串口的方式是不一樣的。
2.2 將Unity3D的API平臺切換成.NET2.0。如何切換“Edit–project Setting–Player–Other Setting –Api Compatibility level”。在這里將“.NET2.0 Subset”切換為“.NET2.0”。
2.3 Unity的目標平臺一定要切換為Windows平臺,否則是其他平臺會報錯誤,本人就是深有體會,針對這個問題找原因找了很久,什么百度、谷歌、論壇都查閱了,最后還是無意中自己發現解決的了。
切換為Web平臺時報的錯誤
3、Unity的串口與.NET的串口對象參數有些不一樣,比如在Unity3D中打開串口,SerialPort對象的屬性、方法、事件等要比.NET SerialPort對象的屬性、事件、方法要少一些。(圖片不能顯示,所以不就貼圖了,只是說明下情況),甚至Unity3D的有些屬性還是錯誤的,比如BytesToRead和BytesToWrite兩個屬性都是“未將對象引用值對象的實例”,但是在.NET中這兩個參數默認是為0。這兩個參數用于接收串口發送字節數組時,是很有用處的。
這是WinForm中串口對象里的屬性
4、虛擬串口的創建,不像是真實串口線那樣子,它是以對來創建的,比如COM100與COM101一對……至于怎么成對完全是有那個創建虛擬串口的軟件以及你輸入的串口來決定的。
二、Unity3D內部通信1、內部通信思路
1.1 打開串口
之前在前言中說過,Unity打開串口方式不一樣,因為在.NET2.0打開串口時,如果串口超過10,則必須在前面加上“\?\”,比如我需要打開COM301,在Unity中你實際傳給串口的參數必須是“”\?\” + “COM301””。
在命名空間中引用System.IO.Ports
創建兩個串口類對象
1.2 線程接收數據
兩個串口接收數據,并且打印出來,一般接收數據的方法常用的有兩種,一種是接收字符串ReadLine()另一種接收字節Read,稍微我會將接收字節已注釋的形式寫出來。
網關接收數據方法
協調器接收數據方法
1.3 發送數據
將這下面兩個方法分別加入到UI Button的事件中,具體如何加這里就不解釋了。
2、代碼
主要類PortsTest.cs,字節字符串轉化類ClassConvert.cs。
3、運行結果和異常解析
運行程序后,會提示網關串口打開成功和協調器串口打開成功。
3.1、當以字符串形式發送串口數據和接收串口數據時,會發現一個問題就是在接收串口數據時,會出現數據丟失的情況,網關串口向協調器發送”FF0000”時,協調器接收數據偶爾會接收到“F0000”甚至是為空,只有當連續發送兩次時,才會成功。
3.2、當以字節發送和接收串口數據時,會出現一條完整的數據會以兩次打印出來。比如將“new byte[] { 0xFF, 0x00, 0x01 }”發送過去,然后打印出來的結果是第一條是FF 第二條是00 01等等情況,感覺像是隨機的。
4、當以字節發送,字符串形式接收時,是無法接收數據的
以上問題目前我也不知道是什么情況,解決思路是怎樣的,發生該問題的原因可能是因為Unity對串口這塊本身支持就不是很大,畢竟不是專門針對Windows平臺的。
三、Unity3D與Winform程序之間的串口通信
在第一部分中介紹了Unity3D內部間的通信,現在測試Unity3D與Winform程序之間的串口通信。
首先Unity3D串口程序跟第一節類似的,只不過把網關打開串口那一部分代碼移植到Winform中,然后修改一下打開串口的方式即可。
1、打開串口方式
以上就是核心代碼。
2、打開串口方式
發送字符串和接收字符串遇到以下發生過的問題
2.1 winform程序發送數據成功了,但是Unity接收不到
2.2 Unity往Winform程序總發送數據時,是沒有問題的。而Unity卻接收不到。
發送字節和接收字節遇到以下發生過的問題
2.3 WinForm程序發送數據成功了,但是Unity接收到的數據存在問題,數據不符或數據中斷,要想解決這個問題有兩種方法:
第一可能是Unity官方的錯誤,如果能做成跟.NET串口通信一致的話,那么這個問題很好解決。不過這個問題不夠現實,因為Unity本身就是為游戲而開發的。
第二那就自己去解決了,看到Unity接收到的數據存在數據不符,還有數據斷層,只能根據自身的要求,然后去測試,添加校驗位,根據首校驗位和末校驗位來截取你想要的字節。只有這樣子你才可能接收到正常的串口數據。但是這樣子也存在很多的局限性!!!
2.4 Unity往WinForm程序中發送的數據時,是沒有問題的。
有問題可關注我的公眾號(Hua灬清),我會每周推送一篇原創技術博客。