Unity Mirror 多人同步 基礎教程
- Mirror
- NetworkManager(網絡管理器)
- Configuration:配置
- Auto-Start Options:自動啟動
- Scene Management:場景管理
- Network Info:網絡信息
- Authentication:身份驗證
- Player Object:玩家對象
- Security:安全
- Snapshot Interpolation:快照插值
- Connection Quality:連接質量
- Interpolation:UI 插值調試 UI
- KcpTransport(KCP 通信協議)
- Transport Configuration:通信配置
- Advanced:高級設置
- Allowed Max Message Sizes:允許的最大消息大小
- Debug:調試
- NetworkManagerHUD(網絡管理器 HUD)
- Offset X / Offset Y:畫面偏移
- 主要方法
- NetworkStartPosition(玩家出生點位置)
- NetworkIdentity(網絡“身份證”)
- Server Only:服務器端
- Visibility:可見性
- NetworkTransformReliable(網絡同步器 穩定版)
- Target:同步物體
- Selective Sync:選擇性同步
- Bandwidth Savings:帶寬優化
- Interpolation:插值平滑
- Coordinate Space:坐標空間
- Timeline Offset:時間偏移修正
- Debug:調試
- Additional Settings:其他設置
- Rotation:旋轉靈敏度
- Precision:位置同步
- Sync Settings:同步設置
- Unity Mirror 示例
- Mirror & ParrelSync 插件以及 ScriptTemplates代碼模板導入
- Mirror 插件 導入
- ParrelSync 插件導入
- ScriptTemplates 代碼模板導入
- ScriptTemplates 代碼 模板 作用
- 你能用它們干什么?
- Mirro 消息發送接收與同步
- Mirror 的“消息發送/接收/同步
- 自定義消息(最靈活、協議自控)
- 遠程調用:Command / Rpc(經典、夠用)
- 高層自動同步:SyncVar / SyncList…
- 如何選擇
- Mirro UGUI 網絡控制
- 代碼里的方法映射
- UGUI 控制代碼
- 腳本搭載
- 常見坑與排查
- Mirro 場景切換功能
- 場景編排器
- 自定義 NetworkManager
- 場景管理
- 腳本搭載以及運行
Mirror是什么?
Mirror是一款免費的開源的可以用于多人網絡聯機的一個庫,其不僅適用于局域網,也可用于專用的服務器(Dedicate Server)C/S模式,適用于Unity 2019/ 2020 / 2021 /2022 /2023 / 6000。其前身是基于Unet構建的,簡化了一些Unet里的api操作,重構并添加了一些新的功能,大部分的概念和Unet是相通的。
Mirror的一些特性包括:
- 消息處理(Message handlers)
- 通用的高性能的序列化(General purpose high performance
- serialization)
- 分布式對象管理
- 狀態同步
- 網絡類,如:Server、Client、Connection等
Mirror由不同的層構建而成:
Mirror
鏈接: GitHub Mirror 下載地址
鏈接: Mirror & ParrelSync & Mirror 模板 ScriptTemplates下載地址
鏈接: Mirror 官方文檔
NetworkManager(網絡管理器)
Configuration:配置
Dont Destroy On Load:是否在切換場景時保持 NetworkManager 不被銷毀。勾選:通常用于只有一個全局 NetworkManager 的項目。不勾選:如果每個場景都有獨立的 NetworkManager。Run In Background:是否允許游戲在后臺繼續運行(比如切出去窗口)。勾選:保證多人游戲網絡不會因應用暫停而斷開。
Auto-Start Options:自動啟動
Headless Start Mode:無頭模式(服務器構建時)啟動行為。 DoNothing:不自動啟動。 Auto Start Server:啟動時自動作為服務器運行。 Auto Start Client:啟動時自動作為客戶端運行。Editor Auto Start:在 Unity Editor 下是否也應用 Headless Start Mode,方便調試。Send Rate:服務器/客戶端每秒發送更新的頻率。高速游戲(FPS):60–100 Hz。 RPG/MMO:30 Hz 左右。 慢節奏(策略/回合):1–10 Hz。
Scene Management:場景管理
Offline Scene:當網絡斷開/停止時切換到的場景。Online Scene:當服務器啟動、客戶端連接成功后切換的場景。Offline Scene Load Delay:從斷開到加載 Offline Scene 的延遲(秒),比如顯示 “連接丟失” 提示。
NetworkManager 是 Mirror 的核心網絡入口,這些參數基本涵蓋了 生命周期(配置/啟動)→ 場景切換 → 連接設置 → 玩家生成 → 安全 → 同步優化。
Network Info:網絡信息
Transport:傳輸層組件(Mirror 提供 KCP/Telepathy/其他自定義 Transport)。Network Address:客戶端連接服務器的 IP 或域名,默認 localhost。Max Connections:最大同時連接的客戶端數量。Disconnect Inactive Connections:是否自動斷開不活躍的連接。Disconnect Inactive Timeout:不活躍多久(秒)后斷開。
Authentication:身份驗證
Authenticator:可選的認證組件(比如用戶名/密碼驗證)。默認為 None,所有連接直接通過。 可自定義擴展。
Player Object:玩家對象
Player Prefab:客戶端連接時生成的玩家對象(必須帶 NetworkIdentity)。Auto Create Player:是否在客戶端連接時自動生成玩家。Player Spawn Method:玩家生成位置的選擇方式: Random:隨機選擇一個 NetworkStartPosition。 RoundRobin:輪流順序分配。
Security:安全
Exceptions Disconnect:如果在處理網絡消息時拋出異常,是否立即斷開該客戶端。開啟:更安全,避免漏洞。 關閉:可能允許客戶端繼續運行,但有風險。
Snapshot Interpolation:快照插值
Snapshot Settings:插值參數,用于平滑同步移動(插幀/預測)。如非必要,不用調整默認就行。
Connection Quality:連接質量
Evaluation Method:評估網絡連接質量的方式:Simple:基于 RTT 和抖動。 Pragmatic:基于插值的調整。Evaluation Interval:多久評估一次連接質量(秒)。
Interpolation:UI 插值調試 UI
Time Interpolation Gui:是否在 Editor/Dev Build 中啟用插值調試 GUI(幫助可視化網絡延遲和插值)。Registered Spawnable Prefabs:可被網絡動態生成的 Prefab 列表。這里需要把游戲中要通過網絡 Spawn 的物體(非玩家)都注冊進來。例如子彈、怪物、掉落物。點擊 Populate Spawnable Prefabs 按鈕可自動添加。
KcpTransport(KCP 通信協議)
Transport Configuration:通信配置
Port:服務器監聽的 UDP 端口號。客戶端需要連接這個端口,常用如 7777 或 25565。Dual Mode:同時支持 IPv4 和 IPv6。? 開啟:更通用,推薦。? 關閉:僅支持 IPv4(在部分設備/網絡環境下更穩定)。No Delay:是否啟用 KCP 的 Nodelay 模式,即立即發包而不是等聚合。開啟:延遲更低,適合實時游戲。關閉:節省帶寬,延遲稍高。Interval (ms):KCP 的內部刷新周期(單位:毫秒)。默認 10ms(比 KCP 原始默認 100ms 要快很多)。越小延遲越低,但 CPU 占用更高。Timeout (ms):超時時間,客戶端多久沒響應就判定掉線。默認 10000ms(10 秒)。Recv Buffer Size / Send Buffer Size (bytes):Socket 的收/發緩沖區大小。默認 7MB 左右。并發高、大流量時要足夠大。操作系統也需要支持這么大的 buffer,否則無效。
Advanced:高級設置
Fast Resend:丟包重傳的激進程度。0:標準模式。2:快速模式,丟包后更快重傳(推薦實時游戲)。Receive Window Size / Send Window Size:接收/發送窗口大小(以包為單位)。默認 4096,代表可以同時緩存/飛行這么多包。窗口越大,吞吐量越高,但丟包時壓力更大。Max Retransmit:單個包的最大重傳次數,超過就判定連接異常。默認 40。Maximize Socket Buffer:是否嘗試將 socket 緩沖區設置到系統允許的最大值。 建議開啟,在高并發/大消息場景下更穩。
Allowed Max Message Sizes:允許的最大消息大小
這些是只讀值,顯示在當前窗口設置下:Reliable Max Message Size:在“可靠通道”下單個消息的最大字節數。Unreliable Max Message Size:在“不可靠通道”下單個消息的最大字節數(一般接近 MTU ~1200 字節)。👉 提示:即使最大值很大,也推薦把大消息拆分為小消息傳輸,否則會導致延遲增加。
Debug:調試
Debug Log:是否打印調試日志。Statistics GUI:是否在屏幕上顯示統計 GUI(僅限 Editor/Dev Build)。Statistics Log:是否定期在控制臺輸出統計信息(方便無頭服務器調試)。
NetworkManagerHUD(網絡管理器 HUD)
Offset X / Offset Y:畫面偏移
Offset X / Offset Y:類型:int 作用:控制 HUD 在屏幕上的 水平偏移 / 垂直偏移(像素)。 默認值:0,表示從屏幕左邊緣開始繪制。 使用場景: 如果你的游戲左上角有其他 UI(例如血條、菜單按鈕),可以通過修改這個值讓 HUD 向右移動,避免重疊。
如果腳本搭載,會在視圖左上角出現這樣的效果,按鈕可點擊執行相應的方法。
主要方法
Host (Server + Client):NetworkManager.StartHost()內部流程:啟動 服務器(StartServer())啟動 本地客戶端(StartClient())用于單機本地測試(既當服務端,又有一個客戶端連入)。
Client:NetworkManager.StartClient()內部流程:使用 manager.networkAddress(默認是 "localhost")和端口去連接服務器。連接成功后會觸發 OnClientConnect()。
Server Only:NetworkManager.StartServer()內部流程:僅啟動服務器,等待遠程客戶端連接。沒有本地玩家。
Client Ready:讓客戶端向服務器聲明“我已準備好”,并生成玩家對象。Stop Host / Stop Client / Stop Server:NetworkManager.StopHost();NetworkManager.StopClient();NetworkManager.StopServer();分別關閉 Host、客戶端、服務器。
小結:Host → StartHost()Client → StartClient()(同時可修改 IP 和端口) Server Only → StartServer() Client Ready → NetworkClient.Ready() + AddPlayer() Stop 系列 → StopHost() / StopClient() / StopServer()
NetworkStartPosition(玩家出生點位置)
如何使用:1. 放幾個點就有幾個可選出生位:給場景里多個空物體加上 NetworkStartPosition,就能形成一個出生點池。2. 朝向也會被用到:玩家會按該 Transform.rotation 生成,擺好面向。3. 換場景/銷毀會自動清理:不必手動管理列表,組件的 OnDestroy 會把自己移除,避免臟引用。3. 與 PlayerSpawnMethod 聯動:在 NetworkManager 里切換 Random / RoundRobin 可改變分配策略(適合大廳或多刷新點地圖)。4. 沒有出生點也能生成:若列表為空,Mirror 會在(0,0,0)或默認位置實例化玩家(取決于你的自定義邏輯);通常建議至少放一個 NetworkStartPosition。
NetworkIdentity(網絡“身份證”)
Server Only:服務器端
說明:如果勾選,表示這個對象 只會存在于服務器,不會同步到客戶端。用途:服務器邏輯物體(如路徑點、服務端專用的管理對象)。怪物尸體復活前隱藏、只在服務器運算等。
Visibility:可見性
說明:決定對象是否廣播給客戶端(可見性覆蓋 Interest Management 系統)。Default → 使用 Interest Management(默認規則,比如 AOI 可見性)。ForceHidden → 強制對所有客戶端不可見(即使理論上在范圍內)。ForceShown → 強制廣播給所有客戶端(比如比分 UI、全局物體)。用途:怪物重生時用 ForceHidden 先隱藏。全局排行榜、房間管理器等用 ForceShown 始終可見。
如何使用:1. 所有可聯網物體必須掛 NetworkIdentity(玩家、子彈、敵人…)。2. 服務器專用邏輯對象 → 勾選 Server Only。 3. 全局廣播對象 → Visibility = ForceShown。 4. 需要臨時隱藏 → Visibility = ForceHidden(如怪物復活)。 5. Prefab 必須有 assetId,不要復制 prefab 時丟失。
NetworkTransformReliable(網絡同步器 穩定版)
Target:同步物體
Target:需要同步的 Transform 對象(一般就是 Player 或附加的子物體)。
Selective Sync:選擇性同步
Sync Position:是否同步位置。Sync Rotation:是否同步旋轉。Sync Scale:是否同步縮放。👉 如果某些屬性不需要頻繁同步(比如縮放固定),可以取消勾選節省帶寬。
Bandwidth Savings:帶寬優化
Only Sync On Change:只有當值變化超過閾值時才同步(位置變化大于 Position Precision、旋轉變化大于 Rotation Sensitivity)。Compress Rotation:使用壓縮四元數(Smallest-3 壓縮),減少數據量。
Interpolation:插值平滑
Interpolate Position / Rotation / Scale:是否在客戶端平滑插值過渡,而不是瞬間跳躍。 ? 勾選 → 畫面流暢,適合角色移動。 ? 關閉 → 精準、即時,適合子彈/爆炸等瞬時事件。
Coordinate Space:坐標空間
Coordinate Space: Local → 同步本地坐標(相對于父物體)。 World → 同步全局坐標。
Timeline Offset:時間偏移修正
Timeline Offset:是否啟用時間偏移修正,用于弱網下抵消網絡延遲造成的“卡頓”。
Debug:調試
Show Gizmos / Show Overlay / Overlay Color:調試功能:在場景視圖或屏幕上顯示插值/同步狀態。
Additional Settings:其他設置
Only Sync On Change Correction Multiplier:(在 Inspector 里叫 Only Sync On Change 值)當啟用 “只在變化時同步” 時,用于修正快照時間的倍數,避免物體第一次移動時出現卡頓。Use Fixed Update:是否在 FixedUpdate 中應用快照(適合物理物體同步),默認 Update。
Rotation:旋轉靈敏度
Rotation Sensitivity:旋轉靈敏度(角度差超過多少度才同步)。默認 0.01。
Precision:位置同步
Position Precision:位置同步的精度(小數點后保留多少)。默認 0.01 ≈ 1cm。Scale Precision:縮放同步的精度,默認 0.01。👉 值越大,帶寬占用越少,但精度也下降。
Sync Settings:同步設置
Sync Direction: Client To Server → 客戶端控制(例如玩家移動)。 Server To Client → 服務器控制(例如怪物 AI)。Sync Interval:同步間隔(秒)。0 表示每幀都可能同步。
總結:
這個組件就是 Mirror 官方的 高精度、低帶寬版 Transform 同步器:1. Selective Sync:決定同步哪些屬性。 2. Bandwidth Savings:減少帶寬消耗(只在變化時發包 + 壓縮)。 3. Interpolation:客戶端平滑移動,避免抖動。 4. Precision / Sensitivity:控制同步的粒度。 5. Sync Direction:誰來作為“權威端”同步數據。
Unity Mirror 示例
鏈接: Mirror & ParrelSync下載地址
Mirror & ParrelSync 插件以及 ScriptTemplates代碼模板導入
Mirror 插件 導入
1. 你解壓或者下載之后,直接拉到 Unity Assets 中。
2. 導入之后最好點擊一下All 然后點擊Import 按鈕。
3. 如果想要了解 可以在 Assets/Mirror/Examples 文件夾下選擇自己感興趣的場景進行嘗試。
4. 我推薦這個場景,整體功能基本上都有大家可以自己嘗試嘗試。場景地址:Assets/Mirror/Examples/TopDownShooter/Scenes/MirrorTopDownShooter
ParrelSync 插件導入
ParrelSync 是一個 Unity 編輯器擴展,允許用戶通過打開另一個 Unity 編輯器窗口并鏡像原始項目的更改來測試多人游戲,而無需構建項目。
👉 注意:克隆的項目不可編輯否則會報錯。
特征:
- 測試多人游戲,無需構建項目
- 用于管理所有項目克隆的 GUI 工具
- 受保護的資產不被其他克隆實例修改
- 方便的 API 可加快測試工作流程
1. 你解壓或者下載之后,直接拉到 Unity Assets 中。只不過選擇的是:ParrelSync 。
2. 導入成功之后可以在頂部導航欄 點擊 ParrelSync->Clones Manager
3. 可以更改自己想要克隆的路徑,點至Open In New Editor 就可以打開鏡像項目了。
4. 最后就是這樣的效果
ScriptTemplates 代碼模板導入
1. 你解壓或者下載之后選擇 ScriptTemplates 文件夾,直接拉到 Unity Assets 中。
2. 導入之后會是這樣,導入成功之后要重啟編輯器。
3. 成功之后在Assets 中鼠標右鍵 Create -> Mirror 就可以創建可種各樣的代碼模板使用了。
ScriptTemplates 代碼 模板 作用
模板名稱 | 基類 | 主要作用 |
---|---|---|
Network Manager | NetworkManager | 核心入口,管理服務器/客戶端的啟動、場景切換、玩家生成等。 |
Network Manager With Actions | NetworkManager | 同上,但額外提供 Action 事件回調,方便用委托而不是繼承來訂閱。 |
Network Authenticator | NetworkAuthenticator | 自定義認證(賬號/密碼/令牌驗證),控制客戶端是否能加入。 |
Network Behaviour | NetworkBehaviour | Mirror 網絡對象的基類,帶有 OnStartServer 、OnStartClient 等生命周期函數。 |
Network Behaviour With Actions | NetworkBehaviour | 在 NetworkBehaviour 基礎上加了事件委托版本,邏輯更解耦。 |
Custom Interest Management | InterestManagement | 控制對象的可見性(只同步范圍內的對象 / 分組廣播)。 |
Network Room Manager | NetworkRoomManager | 內置房間邏輯(大廳/準備/開始游戲/切換場景)。 |
Network Room Player | NetworkRoomPlayer | 房間里玩家的狀態(如準備/未準備、玩家編號),與 Room Manager 配套。 |
Network Discovery | NetworkDiscovery | 局域網房間發現(客戶端廣播 → 服務器回應)。 |
Network Transform | NetworkTransformReliable (或 NetworkTransform ) | 同步對象的 Transform(位置、旋轉、縮放),帶插值和可靠傳輸。 |
你能用它們干什么?
快速搭建多人聯機框架:Network Manager 負責整體網絡。Network Room Manager + Player 負責大廳、準備、進入游戲。 Network Authenticator 控制誰能加入。 Custom Interest Management 控制誰能看到哪些對象。
同步游戲對象:Network Behaviour/With Actions → 寫自定義聯網邏輯(比如血量、技能冷卻)。Network Transform → 同步位置和旋轉,保持客戶端一致。
擴展局域網/發現功能:Network Discovery 允許自動發現服務器(無需手輸 IP)。
? 總結:這些模板就像“起手式”,幫你在寫聯網代碼時不需要每次都從 MonoBehaviour 改成 NetworkBehaviour,再一個個補生命周期。直接選對應的模板,就能快速得到 Mirror 推薦的代碼結構。
Mirro 消息發送接收與同步
Mirror 的“消息發送/接收/同步
1. 高層數據同步:SyncVar / SyncList / SyncDictionary / SyncSet(自動同步,有鉤子)
2. 遠程調用:[Command](Client→Server)、[ClientRpc] / [TargetRpc](Server→Clients/某個Client)
3. 原始消息:NetworkMessage(RegisterHandler + Send,完全自定義協議)
4. Transform 同步:NetworkTransform( Reliable )(位置/旋轉/縮放 + 插值)
自定義消息(最靈活、協議自控)
適合:聊天、房間列表、業務事件等。
核心 API:RegisterHandler<T>()、Send(msg)、conn.Send(msg)、NetworkServer.SendToAll(msg)
// ─────────────────────────────────────────────────────────────────────────────
// 項目:Mirror Demo
// 文件:ChatMessages.cs
// 說明:演示 Mirror 的 NetworkMessage 收發(客戶端→服務器→廣播給所有客戶端)
// ─────────────────────────────────────────────────────────────────────────────using Mirror;
using UnityEngine;public struct ChatMsg_ZH : NetworkMessage
{// 這里放要傳的字段(必須是 public field,不是屬性)public string _Text;
}// 掛到你的 NetworkManager 的同一個對象上更方便初始化
public class ChatMessageHub : MonoBehaviour
{// ───── 服務器端注冊 ─────/// <summary>服務器啟動時注冊消息處理</summary>[ServerCallback]private void OnEnable(){// 客戶端發來的 ChatMsg// false=不要求通過 Auth 才能收此消息:contentReference[oaicite:1]{index=1}NetworkServer.RegisterHandler<ChatMsg>(OnServerChatMsg, false); }/// <summary>服務器關閉時注銷消息處理</summary>[ServerCallback]private void OnDisable(){// 模板里也有示例:contentReference[oaicite:2]{index=2}NetworkServer.UnregisterHandler<ChatMsg>(); }/// <summary>服務器收到客戶端消息 → 回發給所有人</summary>private void OnServerChatMsg(NetworkConnectionToClient _Conn, ChatMsg _Msg){Debug.Log($"[Server] 收到:{_Msg._Text}");// 回給所有客戶端NetworkServer.SendToAll(_Msg);}// ───── 客戶端注冊 ─────/// <summary>客戶端啟動時注冊接收</summary>private void Start(){NetworkClient.RegisterHandler<ChatMsg>(OnClientChatMsg, false); //:contentReference[oaicite:3]{index=3}}/// <summary>客戶端收到服務器(或其他客戶端轉發)的消息</summary>private void OnClientChatMsg(ChatMsg _Msg){Debug.Log($"[Client] 收到:{_Msg._Text}");}// ───── 客戶端發送 ─────/// <summary>客戶端發消息到服務器</summary>public void ClientSend(string _Text){if (!NetworkClient.isConnected) return;//:contentReference[oaicite:4]{index=4}NetworkClient.Send(new ChatMsg { _Text = _Text }); }
}
要點:
結構體必須是 public struct + public 字段,Mirror 自動序列化。
先 RegisterHandler<T>() 再 Send(),否則會丟。
可搭配 KCP 的可靠/不可靠通道(KcpTransport 層),消息體盡量小且高頻時要考慮帶寬(你前面已配好 KCP 參數)。
遠程調用:Command / Rpc(經典、夠用)
適合:權威服模式下的“客戶端輸入→服務器處理→同步給所有客戶端”
using Mirror;
using UnityEngine;public class MoveAbility_ZH : NetworkBehaviour
{[SyncVar(hook = nameof(OnSpeedChanged))] // 值改變自動同步,調用鉤子public float _Speed = 3f;// ───── 客戶端輸入 → 發到服務器 ─────/// <summary>客戶端請求移動</summary>/// <param name="_Dir">移動方向(已歸一化)</param>[Command] // Client→Serverprivate void CmdMove(Vector3 _Dir){if (!isServer) return;// 服務器權威地修改位置(示例:簡單位移)transform.position += _Dir * _Speed * Time.fixedDeltaTime;// 廣播給所有客戶端做一些即時效果RpcOnMoveFx(transform.position);}// ───── 服務器廣播 → 客戶端執行 ─────/// <summary>移動效果(僅客戶端執行)</summary>[ClientRpc] // Server→All Clientsprivate void RpcOnMoveFx(Vector3 _NewPos){// 僅做特效/音效,位置同步可交給 SyncVar 或 NetworkTransform// Debug.DrawLine(oldPos, _NewPos, Color.green, 0.1f);}// ───── SyncVar 鉤子 ─────private void OnSpeedChanged(float _Old, float _New){// 本地 UI 刷新}// ───── 本地采集輸入 ─────private void Update(){if (!hasAuthority) return; // 僅本地玩家采集輸入Vector3 _Dir = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical")).normalized;if (_Dir != Vector3.zero){// 向服務器發命令CmdMove(_Dir);}}
}
要點:
[Command] 只能由擁有對象 authority 的客戶端調用;服務端執行方法體。
[ClientRpc] 由服務端調用、所有客戶端執行;如只給某個玩家:用 [TargetRpc](參數首個是 NetworkConnectionToClient)。
小狀態(數值/開關)優先用 SyncVar,大范圍連續狀態(位置)交給 NetworkTransform。
高層自動同步:SyncVar / SyncList…
適合:數值狀態、裝備表、隊伍列表等。
using Mirror;
using UnityEngine;/// <summary>
/// 同步生命與物品列表的示例
/// </summary>
public class StatsAndBag_ZH : NetworkBehaviour
{// 數值:改一次→自動同步給觀察者[SyncVar(hook = nameof(OnHpChanged))]public int _Hp = 100;// 列表:增刪改→逐項同步public readonly SyncList<string> _Items = new SyncList<string>();[Server]public void ServerTakeDamage(int _Value){_Hp = Mathf.Max(0, _Hp - _Value); // 賦值會觸發同步 + 鉤子}private void OnHpChanged(int _Old, int _New){// 刷 UI、播放受擊等}private void Awake(){// 監聽同步列表事件_Items.Callback += (_Op, _Index, _OldItem, _NewItem) =>{// 根據 _Op(Add/Remove/Insert/Set)刷新 UI};}
}
要點:
SyncVar 適合小而離散的數據;SyncList 適合集合數據。
只有服務器改動的值才會被同步(默認權威)。客戶端想改 → 用 Command 請求服務器。
如何選擇
1. 玩家輸入/交互:Command 上行 → 服務器改狀態 → SyncVar/ClientRpc 下發
2. 屬性數值:SyncVar + hook
3. 集合/背包:SyncList / SyncDictionary
4. Transform:NetworkTransformReliable(或自定義 NetworkTransformBase)
5. 雜項業務事件(聊天/房間/公告):NetworkMessage(Register + Send)
6. 篩可見性/降低帶寬:自定義 InterestManagement 限制 Observer
Mirro UGUI 網絡控制
代碼里的方法映射
1. Start Host → OnClickStartHost() → NetworkManager.StartHost()(禁用三鍵防連點)。
2. Start Client → OnClickStartClient():讀取 _AddressInputField 與 _PortInputField,設置 networkAddress 和 KcpTransport.Port → StartClient()。
3. Start Server → OnClickStartServer():設置端口 → StartServer()(可選:切換到 online 場景的示例協程已給出,默認為注釋)。
4. Stop → StopButtons():根據當前狀態調用 StopHost() / StopClient() / StopServer()。
UGUI 控制代碼
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Mirror;
using UnityEngine.UI;
using Mirror.BouncyCastle.Bcpg.OpenPgp;
using Newtonsoft.Json.Serialization;
using UnityEngine.SceneManagement;[AddComponentMenu("NetHUD/NetworkManagerHUD_ZH")]
public class NetworkManagerHUD_ZH : MonoBehaviour
{NetworkManager _Manager;//開啟按鈕組public GameObject _StartButonsGroup;//停止按鈕組public GameObject _StopButtonsGroup;//顯示狀態按鈕public Text _StatusText;//創建Hostpublic Button _StartHostButton;//創建 clientpublic Button _StartClientButton;//IP地址輸入框public InputField _AddressInputField;//端口輸入框public InputField _PortInputField;//創建服務器public Button _StartServerButton;//停止Hostpublic Button _StopHostButton;// ───── 單例防重 + 常駐 ─────private static NetworkManagerHUD_ZH _Instance;private void Awake(){// 防止切換場景后出現第二個 HUDif (_Instance != null && _Instance != this){Destroy(gameObject);return;}_Instance = this;// 關鍵:切場景不銷毀DontDestroyOnLoad(gameObject);}void Start(){//獲取組件_Manager = NetworkManager.singleton ?? FindObjectOfType<NetworkManager>();// 先清理,防止因重復綁定導致一個點擊觸發兩次_StartHostButton.onClick.RemoveAllListeners();_StartClientButton.onClick.RemoveAllListeners();_StartServerButton.onClick.RemoveAllListeners();_StopHostButton.onClick.RemoveAllListeners();_StartHostButton.onClick.AddListener(OnClickStartHost);_StartClientButton.onClick.AddListener(OnClickStartClient);_StartServerButton.onClick.AddListener(OnClickStartServer);_StopHostButton.onClick.AddListener(StopButtons);}void Update(){// UI 狀態刷新StatusLabels();bool _IsHost = NetworkServer.active && NetworkClient.active;bool _IsServer = NetworkServer.active && !NetworkClient.active;bool _IsClient = NetworkClient.isConnected && !NetworkServer.active;//根據狀態顯示按鈕if (!_IsHost && !_IsServer && !_IsClient){// 如果我們還沒有連接,則允許更改地址if (!NetworkClient.active){// 未連接_Manager.networkAddress = _AddressInputField.text;//只有當我們有端口傳輸時才顯示端口字段//我們不能在address字段中使用“IP:PORT”,因為只有這個字段//支持IPV4:PORT。//對于IPV6:PORT,這可能會誤導,因為IPV6包含“:”:// 2001:0db8: 0000:0000:0000: ff00: 0042:8329if (Transport.active is PortTransport portTransport){// 如果有人試圖輸入非數字字符,請使用TryParseif (ushort.TryParse(_PortInputField.text, out ushort port)){portTransport.Port = port;}// 狀態顯示為空_StatusText.text = "";}}else{// 正在連接中_StatusText.text = ($"Connecting to {_Manager.networkAddress}..");}_StartButonsGroup.SetActive(true);_StopButtonsGroup.SetActive(false);}else{_StartButonsGroup.SetActive(false);_StopButtonsGroup.SetActive(true);}}private void OnEnable(){// 有些項目把 NetworkManager 放在玩法場景里,切場景后需要重新拿引用UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnActiveSceneChanged;}private void OnDisable(){// 解綁事件UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnActiveSceneChanged;}/// <summary>/// 活動場景已更改/// </summary>/// <param name="oldS"></param>/// <param name="newS"></param>private void OnActiveSceneChanged(Scene oldS, UnityEngine.SceneManagement.Scene newS){// 場景切換后,重新拿一次 NetworkManager / Transport 等(以防丟引用)if (_Manager == null){_Manager = FindObjectOfType<NetworkManager>();}// 這里一般不需要重新綁按鈕,因為按鈕在本對象上,隨著 HUD 常駐一起在// 如果你的按鈕是在場景里的別的對象,需要在這里重新查找并綁定}/// <summary>/// 點擊創建 Host/// </summary>public void OnClickStartHost(){// 禁止連點_StartHostButton.interactable = false;_StartClientButton.interactable = false;_StartServerButton.interactable = false;// 設置地址和端口_Manager.StartHost();//// 切換場景 onlineScene 要設置為空//StartCoroutine(Co_SwitchOnlineSceneOnce("MyScene"));//if (!string.IsNullOrWhiteSpace(_Manager.onlineScene))//{// _Manager.ServerChangeScene(_Manager.onlineScene);//}}/// <summary>/// 點擊創建 Client/// </summary>public void OnClickStartClient(){// 設置地址_Manager.networkAddress = _AddressInputField.text;// 設置地址和端口if (Transport.active is PortTransport portTransport &&ushort.TryParse(_PortInputField.text, out ushort port)){portTransport.Port = port;}// 啟動客戶端_Manager.StartClient();}/// <summary>/// 點擊創建 Server/// </summary>public void OnClickStartServer(){//if (int.TryParse(_PortInputField.text, out int port))//{// _Manager.GetComponent<TelepathyTransport>().port = (ushort)port;//}//_Manager.StartServer();// 設置地址和端口if (Transport.active is PortTransport portTransport &&ushort.TryParse(_PortInputField.text, out ushort port)){portTransport.Port = port;}// 啟動服務器_Manager.StartServer();// 切換場景 onlineScene 要設置為空//StartCoroutine(Co_SwitchOnlineSceneOnce("MyScene"));//if (!string.IsNullOrWhiteSpace(_Manager.onlineScene))//{// _Manager.ServerChangeScene(_Manager.onlineScene);//}}/// <summary>/// 停止按鈕方法/// </summary>public void StopButtons(){// 如果同時是服務器和客戶端(Host)if (NetworkServer.active && NetworkClient.isConnected){_Manager.StopHost();print("停止主機");}// 停止客戶端(如果處于客戶端模式)else if (NetworkClient.isConnected){_Manager.StopClient();Debug.Log("停止客戶端");}// 停止服務器(如果處于服務器模式)else if (NetworkServer.active){_Manager.StopServer();print("停止服務器");}}/// <summary>/// UI 狀態刷新方法/// </summary>private void StatusLabels(){// 主機模式if (NetworkServer.active && NetworkClient.active){// 主機模式_StatusText.text=($"<b>Host</b>: running via {Transport.active}");}else if (NetworkServer.active){// 僅服務器端_StatusText.text = ($"<b>Server</b>: running via {Transport.active}");}else if (NetworkClient.isConnected){// 僅限客戶端_StatusText.text = ($"<b>Client</b>: connected to {_Manager.networkAddress} via {Transport.active}");}}private IEnumerator Co_SwitchOnlineSceneOnce(string _SceneName){// 等到服務器真正啟動 & 不在加載中yield return new WaitUntil(() => NetworkServer.active && !NetworkServer.isLoadingScene);// 再檢查當前場景是否已經是你想去的那個if (SceneManager.GetActiveScene().name != _SceneName){_Manager.ServerChangeScene(_SceneName);//_Manager.onlineScene = _SceneName;}}
}
腳本搭載
1. 注意物體搭載附加
2. Canvas 自己創建就行,可以按照自己的風格進行處理。
常見坑與排查
1. 按鈕沒反應:確認 Button 的 onClick 沒被別的腳本覆蓋;此腳本里已 RemoveAllListeners() 然后重新綁定,避免重復觸發。
2. 端口不生效:確保當前激活的傳輸層是實現了 PortTransport 的(如 KcpTransport),并且輸入的是數字(腳本用 ushort.TryParse 做了校驗)。
3. 切場景后 HUD 重復:腳本已有“單例防重”邏輯;如果你又在新場景放了一個 HUD,會被自動銷毀保留第一個。
4. 連外網失敗:服務器需要開放 UDP 端口;客戶端地址要填公網 IP,或者配合 NetworkDiscovery 做局域網發現。
Mirro 場景切換功能
場景編排器
using System.Collections;
using System.Collections.Generic;
using Mirror;
using UnityEngine;
using UnityEngine.SceneManagement;/// <summary>
/// 場景編排器(Scene Orchestrator)
/// 功能:
/// 1. 服務器權威管理 Additive 子場景的加載與卸載;
/// 2. 使用 SyncList<string> 同步子場景狀態到所有客戶端;
/// 3. 客戶端(含晚加入)會根據列表自動對齊場景加載狀態。
/// </summary>
public class SceneOrchestrator_ZH : NetworkBehaviour
{// ───── 同步列表 ─────// 當前應加載的子場景列表(服務器寫入,客戶端跟隨)public readonly SyncList<string> _LoadedAdditives = new SyncList<string>();// ───── 本地狀態防抖 ─────// 記錄正在加載的子場景,避免重復調用private readonly HashSet<string> _Loading = new HashSet<string>();// 記錄正在卸載的子場景,避免重復調用private readonly HashSet<string> _Unloading = new HashSet<string>();// ───── Server 端 API ─────#region Server API/// <summary>/// 服務器端:加載一組 Additive 場景(已加載/正在加載的會自動跳過)/// </summary>[Server]public void ServerLoadAdditivesOnce(IEnumerable<string> _Names){StartCoroutine(Co_ServerLoadAdditivesOnce(_Names));}/// <summary>/// 服務器端:切換到新的 Additive 集合(方案1:清空后再加載)/// </summary>[Server]public void ServerSwitchAdditiveSet(IEnumerable<string> _NewSet){StartCoroutine(Co_ServerResetAndApply(_NewSet));}/// <summary>/// 協程:清空舊列表 → 直接加載新集合(避免卸載無效場景報錯)/// </summary>private IEnumerator Co_ServerResetAndApply(IEnumerable<string> _NewSet){// 清空同步列表(客戶端收到 CLEAR 事件,會卸掉所有 Additive)_LoadedAdditives.Clear();yield return null; // 給客戶端一幀時間處理// 直接加載目標集合yield return Co_ServerLoadAdditivesOnce(_NewSet);}/// <summary>/// 協程:僅加載缺失的 Additive 場景/// </summary>private IEnumerator Co_ServerLoadAdditivesOnce(IEnumerable<string> _SceneNames){// 遍歷請求的場景名foreach (var _Name in _SceneNames){// 跳過空名、已加載的、正在加載的if (string.IsNullOrWhiteSpace(_Name)) continue;if (_LoadedAdditives.Contains(_Name)) continue;if (_Loading.Contains(_Name)) continue;// 加載場景_Loading.Add(_Name);// 注意:這里不需要檢查場景是否存在于 Build Settings 中var _Op = SceneManager.LoadSceneAsync(_Name, LoadSceneMode.Additive);while (!_Op.isDone) yield return null;_Loading.Remove(_Name);// 更新同步列表if (!_LoadedAdditives.Contains(_Name)){_LoadedAdditives.Add(_Name); // 同步到客戶端}}}#endregion// ───── Client 端同步邏輯 ─────#region Client Sync/// <summary>/// 客戶端啟動時:做一次全集對齊,并注冊列表回調/// </summary>public override void OnStartClient(){// 保留基類調用,確保 Mirror 內部邏輯不丟失base.OnStartClient();StartCoroutine(Co_ClientApplyFullList()); // 晚加入對齊// 注冊列表變化回調_LoadedAdditives.Callback += OnLoadedAdditivesChanged;}/// <summary>/// 客戶端關閉時:移除列表回調/// </summary>public override void OnStopClient(){// 移除列表變化回調_LoadedAdditives.Callback -= OnLoadedAdditivesChanged;base.OnStopClient();}/// <summary>/// 同步列表變化時的回調/// </summary>private void OnLoadedAdditivesChanged(SyncList<string>.Operation _Op, int _Index, string _OldItem, string _NewItem){// 根據操作類型處理switch (_Op){case SyncList<string>.Operation.OP_ADD:// 新增場景if (!string.IsNullOrEmpty(_NewItem)){StartCoroutine(Co_ClientEnsureLoaded(_NewItem));}break;case SyncList<string>.Operation.OP_REMOVEAT:// 移除場景if (!string.IsNullOrEmpty(_OldItem)){StartCoroutine(Co_ClientEnsureUnloaded(_OldItem));}break;case SyncList<string>.Operation.OP_CLEAR:// 清空列表StartCoroutine(Co_ClientUnloadAll());break;}}/// <summary>/// 客戶端:全集對齊(卸掉多余的,加載缺的)/// </summary>private IEnumerator Co_ClientApplyFullList(){// 卸掉本地多余的(根場景除外)for (int _i = 0; _i < SceneManager.sceneCount; ++_i){// 跳過根場景var _Sc = SceneManager.GetSceneAt(_i);if (_Sc == SceneManager.GetActiveScene()) continue;// 如果不在同步列表里,就卸掉if (!_LoadedAdditives.Contains(_Sc.name)){yield return Co_ClientEnsureUnloaded(_Sc.name);}}// 加載缺失的foreach (var _Name in _LoadedAdditives){// 跳過空名yield return Co_ClientEnsureLoaded(_Name);}}/// <summary>/// 客戶端:確保場景已加載/// </summary>private IEnumerator Co_ClientEnsureLoaded(string _SceneName){// 跳過空名if (string.IsNullOrWhiteSpace(_SceneName)) yield break;// 跳過已加載的和正在加載的var _Sc = SceneManager.GetSceneByName(_SceneName);if (_Sc.IsValid() && _Sc.isLoaded) yield break;if (_Loading.Contains(_SceneName)) yield break;// 加載場景_Loading.Add(_SceneName);var _Op = SceneManager.LoadSceneAsync(_SceneName, LoadSceneMode.Additive);// 注意:這里不需要檢查場景是否存在于 Build Settings 中while (!_Op.isDone) yield return null;_Loading.Remove(_SceneName);}/// <summary>/// 客戶端:確保場景已卸載/// </summary>private IEnumerator Co_ClientEnsureUnloaded(string _SceneName){// 跳過空名if (string.IsNullOrWhiteSpace(_SceneName)) yield break;// 跳過未加載的和正在卸載的var _Sc = SceneManager.GetSceneByName(_SceneName);if (!_Sc.IsValid() || !_Sc.isLoaded) yield break;if (_Unloading.Contains(_SceneName)) yield break;// 卸載場景_Unloading.Add(_SceneName);var _Op = SceneManager.UnloadSceneAsync(_SceneName);// 注意:這里不需要檢查場景是否存在于 Build Settings 中while (_Op != null && !_Op.isDone) yield return null;_Unloading.Remove(_SceneName);}/// <summary>/// 客戶端:卸載所有非根場景/// </summary>private IEnumerator Co_ClientUnloadAll(){// 遍歷所有場景,卸掉非根場景for (int i = 0; i < SceneManager.sceneCount; ++i){// 跳過根場景var _Sc = SceneManager.GetSceneAt(i);if (_Sc == SceneManager.GetActiveScene()) continue;// 卸掉場景yield return Co_ClientEnsureUnloaded(_Sc.name);}}#endregion
}
自定義 NetworkManager
using Mirror;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.SceneManagement;
using System.IO;/// <summary>
/// 自定義 NetworkManager:
/// 1. 擴展 Mirror 自帶的場景切換邏輯;
/// 2. 在根場景切換完成后,服務器權威地加載指定的 Additive 子場景;
/// 3. 通過 SceneOrchestrator_ZH 同步給所有客戶端,保證晚加入客戶端也能正確對齊。
/// </summary>
public class CustomNetworkManager_ZH : NetworkManager
{// ───── 預制體引用 ─────[Header("Orchestrator 預制體(已在 Spawnable Prefabs 中注冊)")]public SceneOrchestrator_ZH _OrchestratorPrefab; // 用于管理 Additive 子場景的網絡對象預制體// ───── 根場景與其對應的 Additive 集合映射 ─────[Header("根場景 → Additive 集合映射")]public List<string> _AdditivesForMyScene = new List<string> { "Add", "GameList" }; // 當根場景是 MyScene 時要加載的子場景public List<string> _AdditivesForMyotherScene = new List<string> { "Add", "GameList" }; // 當根場景是 MyOtherScene 時要加載的子場景// 服務器側持有的 orchestrator 實例(單例)private SceneOrchestrator_ZH _ServerOrchestrator;// ───── 玩家生成防重 ─────/// <summary>/// 重寫 Mirror 的 OnServerAddPlayer,避免重復給同一個連接添加玩家。/// </summary>public override void OnServerAddPlayer(NetworkConnectionToClient _Conn){if (_Conn.identity != null){Debug.LogWarning($"[Server] 添加玩家操作被忽略(連接已存在玩家對象) connId={_Conn.connectionId}");return;}base.OnServerAddPlayer(_Conn);}// ───── 場景切換鉤子 ─────/// <summary>/// 當服務器端完成根場景切換時調用。/// Mirror 會在 ServerChangeScene → FinishLoadScene → OnServerSceneChanged 順序觸發。/// </summary>public override void OnServerSceneChanged(string _SceneName){Debug.Log($"[Server] OnServerSceneChanged -> {_SceneName}");// 保留基類調用,確保 Mirror 內部邏輯不丟失base.OnServerSceneChanged(_SceneName);if (!NetworkServer.active) return;// 開啟協程,等根場景完全切換完成后再裝配 AdditiveStartCoroutine(Co_PostSceneChanged(_SceneName));}// ───── 協程:根場景切換完成后再加載 Additive 集 ─────/// <summary>/// 根場景切換后的后處理邏輯:/// 1. 等待場景完全切換完成;/// 2. 規范化根場景名(去除路徑和后綴);/// 3. 如果 orchestrator 不存在,則生成并 Spawn;/// 4. 按映射選擇要加載的 Additive 集,并調用 orchestrator 同步加載。/// </summary>/// <param name="_ScenePathOrName">傳入的場景路徑或名字(Mirror 傳的可能是完整路徑)</param>private IEnumerator Co_PostSceneChanged(string _ScenePathOrName){// 等待 Mirror 把根場景切換完畢yield return new WaitUntil(() => !NetworkServer.isLoadingScene);yield return null; // 再等一幀更穩// 從路徑提取出純場景名string _RootName = Path.GetFileNameWithoutExtension(_ScenePathOrName);Debug.Log($"[Server] RootSceneName 規范化后 = {_RootName}");// 如果 orchestrator 還沒生成,就在服務器端實例化并 Spawnif (_ServerOrchestrator == null){_ServerOrchestrator = Instantiate(_OrchestratorPrefab);DontDestroyOnLoad(_ServerOrchestrator.gameObject); // 保持跨場景不銷毀NetworkServer.Spawn(_ServerOrchestrator.gameObject); // 廣播給所有客戶端}// 按根場景名選擇要加載的 Additive 集List<string> _Set = null;if (_RootName == "MyScene") _Set = _AdditivesForMyScene;else if (_RootName == "MyOtherScene" || _RootName == "MyotherScene") _Set = _AdditivesForMyotherScene;else _Set = new List<string>(); // 未配置的根場景 → 不加載任何 Additive// 調用 orchestrator 執行子場景加載(服務器權威,客戶端跟隨)_ServerOrchestrator.ServerSwitchAdditiveSet(_Set);Debug.Log($"[Server] Additive 集裝配完成:[{string.Join(", ", _Set)}]");}// ───── 工具函數:校驗子場景是否在 Build Settings 中 ─────/// <summary>/// 檢查 Additive 場景是否都已加入 Build Settings。/// 避免運行時報 “場景未找到”。/// </summary>/// <param name="_Names">要校驗的子場景列表</param>private bool CheckScenesInBuild(List<string> _Names){// 空列表直接通過if (_Names == null) return true;// 遍歷檢查每個場景名for (int i = 0; i < _Names.Count; i++){// 跳過空白項var _N = _Names[i];if (string.IsNullOrWhiteSpace(_N)) continue;// 查找是否存在bool _Exists = false;// 遍歷 Build Settings 里的場景for (int _Bi = 0; _Bi < SceneManager.sceneCountInBuildSettings; _Bi++){var _Path = SceneUtility.GetScenePathByBuildIndex(_Bi);var _NameOnly = System.IO.Path.GetFileNameWithoutExtension(_Path);if (_NameOnly == _N) { _Exists = true; break; }}// 報錯并返回if (!_Exists){Debug.LogError($"[BuildSettings] 缺少場景:{_N}");return false;}}return true;}
}
場景管理
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;/// <summary>
/// 場景管理腳本:
/// - 管理 UI 文本顯示(子彈數量、消息內容);
/// - 負責調用玩家腳本的消息接口;
/// - 提供按鈕觸發的場景切換邏輯。
/// </summary>
public class SceneScript_ZH : NetworkBehaviour
{[Header("UI 引用")]public Text _BulletText; // 顯示子彈數量public Text _MessageText; // 顯示消息文本[Header("玩家引用")]public PlayerController_ZH _PlayerController; // 玩家腳本引用[SyncVar(hook = nameof(OnStatusTextChanged))]public string _StatusText; // 同步消息內容(帶鉤子)/// <summary>/// 當 _StatusText 發生變化時調用,刷新 UI。/// </summary>private void OnStatusTextChanged(string _Old, string _NewStr){_MessageText.text = _StatusText;}/// <summary>/// 按鈕:發送消息/// </summary>public void OnSendMessageButton(){if (_PlayerController != null){_PlayerController.CmdSendPlayerMessage();}}/// <summary>/// 按鈕:切換場景(僅服務器可操作)/// </summary>public void ChangeSceneButton(){// 檢查服務器是否已啟動,是獨立服務器還是作為主機服務器。if (!NetworkServer.active){Debug.Log("只有服務器/主機可以切換場景");return;}// 檢查是否正在切換場景中if (NetworkServer.isLoadingScene){Debug.Log("正在切換場景中,忽略重復請求");return;}// 決定下一個場景string _Cur = SceneManager.GetActiveScene().name;string _NextRoot = (_Cur == "MyScene") ? "MyOtherScene" : "MyScene";// 如果當前場景就是目標場景,則不切換if (_Cur == _NextRoot) return;// 切換場景NetworkManager.singleton.ServerChangeScene(_NextRoot);Debug.Log($"切根場景到:{_NextRoot}");}
}
腳本搭載以及運行
1. 自定義 NetworkManager 搭載:
2. 注意 場景編排器預制體的創建以及切換場景和附加場景名稱添加。
3. 點擊創建 Host But 創建房間
4. 房間創建的時候 會在CustomNetworkManager_ZH 腳本中自動執行 OnServerSceneChanged 方法。然后會執行Co_PostSceneChanged 協程方法,按映射加載 Additive集并調用 orchestrator同步加載。
5. 點擊 Change Scene 按鈕,調用 SceneScript_ZH.ChangeSceneButton() 方法,進行游戲場景切換。
6. 如果切換成功之后,會根據場景名稱進行加載附加場景集。在 CustomNetworkManager_ZH代碼中,根場景 → Additive 集合映射。
7. 執行順序會在 Console 窗口中進行顯示。
鏈接: Unity Mirror 多人同步 基礎教程 完整示例工程
暫時先這樣吧,如果實在看不明白就留言,看到我會回復的。希望這個教程對您有幫助!
路漫漫其修遠,與君共勉。