Unity多人游戏开发:基于Mirror实现网络同步与联机框架搭建

📅 2026/7/27 4:35:57
Unity多人游戏开发:基于Mirror实现网络同步与联机框架搭建
1. 项目概述从单机到联机的思维跃迁做游戏开发尤其是独立开发者很多时候都是从单机游戏开始的。你一个人或者一个小团队把所有的逻辑、美术、交互都塞进一个客户端里测试的时候一切尽在掌握。但当你开始琢磨“要是能和朋友们一起玩就好了”这个念头时整个开发世界就变得复杂了一个维度。这就是我几年前决定把手头的一个小Demo做成多人联机游戏时最直观的感受。Unity引擎本身提供了强大的单机开发能力但多人同步这块它更像是一个“毛坯房”你需要自己选装修方案。当时我评估了几个主流方案Unity官方的Netcode for GameObjects当时还叫UNET后来演进为Netcode、Photon、以及Mirror。官方的方案迭代快但早期版本坑多文档也相对零散Photon是成熟的商业方案服务器托管省心但需要按在线人数付费对于个人项目或者小团队试水来说成本是个需要考虑的因素。最终我选择了Mirror。原因很简单它开源、免费、社区活跃并且最重要的是它的API设计对Unity开发者非常友好几乎可以看作是对早期UNET High-Level API的一次现代化、稳定化的重构。你可以把它理解为你自己搭建了一个多人游戏的“骨架”而Mirror帮你把网络通信、对象同步、远程调用这些最繁琐、最容易出错的“神经系统”给标准化了。这个系列我就想从一个实战者的角度和你聊聊怎么用Mirror一步步把一个单机游戏改造成一个能稳定运行的多人同步游戏。我会避开那些教科书式的概念罗列直接切入我们最常遇到的场景和问题比如玩家的移动怎么同步才不显得“飘”敌人的AI攻击怎么让所有客户端看到一致的结果一个宝箱被打开了怎么确保所有玩家看到的都是“已打开”状态这些才是真正卡住项目进度的细节。今天这第一篇我们先解决最基础、也最核心的问题搭建一个最小可运行的Mirror多人游戏框架并理解其同步的基本原理。2. 环境准备与Mirror入门在开始敲代码之前我们得先把“工地”清理好把工具备齐。Mirror不是一个魔法黑盒它是一套运行在你项目中的代码库所以第一步就是把它请进来。2.1 安装Mirror网络库目前最推荐的方式是通过Unity的Package Manager从Git URL安装这样可以确保获取到最新且稳定的版本。打开你的Unity项目建议使用2020.3 LTS或更新版本稳定性有保障。在顶部菜单栏选择Window-Package Manager。在Package Manager窗口点击左上角的“”号选择“Add package from git URL...”。在弹出的输入框中粘贴Mirror在GitHub上的发布版地址。你可以去Mirror的GitHub仓库的Release页面找到最新的稳定版链接格式通常像这样https://github.com/MirrorNetworking/Mirror.git#v68.0.0请将v68.0.0替换为当时的最新版本号。使用发布版而非主分支可以避免开发中的不稳定代码。点击“Add”Unity就会开始下载并导入Mirror。这个过程可能会花点时间导入后控制台可能会有些编译信息属于正常现象。注意有些教程会教你把Mirror的源码直接拖进Assets文件夹。这种方式不是不行但通过Package Manager管理未来更新、回滚版本会更方便也更容易处理依赖关系。这是我从维护多个项目中学到的一个好习惯。安装完成后你会在Project窗口看到Packages下面出现了Mirror。同时Unity的菜单栏会多出一个“Mirror”菜单项里面有一些便捷工具比如“Network Scene”设置我们后面会用到。2.2 理解Mirror的核心组件NetworkManager如果说Mirror是一个乐团那么NetworkManager就是乐团的指挥。它几乎管理了多人游戏会话的一切启动服务器、连接客户端、玩家生成、场景切换。你不需要从头写一个Mirror提供了一个功能齐全的预制件。在Hierarchy中右键选择Create Empty创建一个空游戏对象可以重命名为“NetworkManager”。选中这个对象在Inspector中点击“Add Component”搜索并添加NetworkManager组件。你会看到这个组件有很多字段别被吓到我们初期只关注几个关键的Player Prefab: 这是最重要的设置之一。它定义了当玩家连接到服务器时服务器会在哪个位置生成一个什么样的游戏对象来代表他。你现在可以暂时留空我们稍后创建了玩家预制件再拖进来。Network Address与Network Port: 服务器的地址和端口。在编辑器内测试时地址通常是“localhost”或“127.0.0.1”。Offline Scene/Online Scene: 定义玩家断开连接后和进入连接后的场景。这对于处理场景切换逻辑很有帮助。为了让NetworkManager在场景加载时自动存在通常我们希望在多个场景中都保持网络连接我们可以把它做成一个“常驻”对象。但更简单的方法是直接使用Mirror提供的预制件在Project窗口中搜索“NetworkManager”找到Mirror包里的预制件直接拖入场景。这个预制件已经配置好了很多默认设置并且自带了一个NetworkManagerHUD组件它会提供一个简单的UI方便我们在编辑器里快速测试“启动服务器”、“启动客户端”、“连接”等操作。2.3 创建你的第一个可同步对象玩家角色在多人游戏中不是每个游戏对象都需要在网络间同步。只有那些状态需要被所有客户端知晓的对象我们才需要将其设置为“网络对象”。Mirror通过NetworkIdentity组件来标记一个对象是可同步的。准备你的玩家模型可以是一个Cube也可以是一个复杂的角色模型。把它做成一个预制件Prefab。选中这个预制件在Inspector中点击“Add Component”添加NetworkIdentity组件。这个组件是“网络身份证”告诉Mirror“嘿我这个对象是需要被服务器管理和同步的。”现在回到之前创建的NetworkManager游戏对象上找到NetworkManager组件将你刚刚添加了NetworkIdentity的玩家预制件拖拽到Player Prefab字段中。这样基础设置就完成了。NetworkManager知道了当新玩家加入时应该实例化哪个预制件作为他的化身。3. 核心同步机制深度解析环境搭好了我们来聊聊Mirror是怎么让数据在不同电脑间“对齐”的。理解这个你才能写出正确、高效的同步代码而不是盲目地试错。3.1 状态同步 vs. 指令同步这是多人游戏同步的两大基本哲学Mirror对两者都提供了支持但适用场景不同。状态同步State Synchronization服务器是唯一的权威Authoritative。客户端只负责发送输入指令如“向前移动”服务器收到后计算游戏逻辑得出所有对象的最新状态如玩家的新位置、敌人的新血量然后将这些状态定期广播给所有客户端。客户端收到状态后直接更新本地对象或者进行平滑插值。Mirror通过[SyncVar]属性和NetworkTransform组件来实现状态同步。优点反作弊能力强逻辑一致性高网络流量相对可预测因为只同步结果状态。缺点对服务器计算压力大客户端响应有延迟因为要等服务器回传状态。指令同步Input/Command Synchronization客户端是部分权威。客户端执行操作并立即在本地看到效果比如开枪同时将操作指令发送给服务器。服务器验证后例如检查是否在射程内再广播给其他客户端让他们也执行同样的指令。Mirror通过[Command]和[ClientRpc]属性来实现指令同步。优点客户端响应极快用户体验好无延迟感服务器压力小只做验证和转发。缺点反作弊依赖服务器验证逻辑一致性维护更复杂网络流量可能更大因为要同步每个操作指令。在实际项目中通常是混合使用。比如玩家的基础移动位置、旋转常用状态同步用NetworkTransform因为这样服务器好管理碰撞和位置验证。而玩家的技能释放、射击这类需要即时反馈的操作则常用指令同步以保证操作的跟手性。3.2 权威性Authority概念谁说了算这是理解Mirror同步的钥匙。每个带有NetworkIdentity的对象都有一个“Authority”的概念。简单说就是谁有权决定这个对象的命运。服务器权威Server Authority这是默认且最常用的模式。服务器拥有所有网络对象的最高权威。[SyncVar]的变更只在服务器端代码执行时才有效然后由服务器同步给客户端。[Command]以Cmd开头的方法虽然从客户端调用但实际是在服务器上执行。客户端权威Client Authority可以将某个特定对象的权威授予某个客户端。例如玩家控制自己的角色时你可以给该玩家角色的NetworkIdentity勾选“Client Authority”。这样该客户端对这个角色的某些操作如移动预测可以更有主动权但服务器依然保留最终裁决权以防止作弊。一个常见的误区是以为在客户端修改了[SyncVar]标记的变量其他玩家就能看到。这是行不通的。[SyncVar]的同步是单向的服务器 - 所有客户端。客户端修改本地变量是无效的不会被同步。3.3 远程过程调用RPC详解让代码在另一端执行这是Mirror实现复杂交互的利器。它允许你在一个客户端或服务器上调用一个函数并让它在另一个客户端或服务器上执行。[Command](Cmd): 从客户端调用在服务器上执行。函数名必须以“Cmd”开头。用于客户端向服务器发送请求如“玩家请求攻击”。[Command] void CmdFireWeapon(Vector3 aimDirection) { // 这段代码在服务器上运行 // 在这里进行伤害计算、验证逻辑 // 然后通过 ClientRpc 或 Spawn 效果对象让所有客户端看到 RpcShowMuzzleFlash(); // 告诉所有客户端播放枪口火焰 }注意[Command]方法默认只能由具有该对象权威的客户端调用。对于玩家角色通常就是本地玩家自己。这防止了客户端随意调用其他玩家的命令。[ClientRpc](Rpc): 从服务器调用在所有客户端或排除发送者的客户端上执行。函数名必须以“Rpc”开头。用于服务器通知所有客户端某个事件的发生如“播放爆炸特效”、“更新所有玩家的得分板”。[ClientRpc] void RpcShowMuzzleFlash() { // 这段代码在每个客户端包括主机客户端上运行 muzzleFlash.Play(); }你可以使用[ClientRpc(target RpcTarget.*)]来指定目标比如RpcTarget.All默认所有人RpcTarget.Others除发送者外的所有人。[TargetRpc](Target): 从服务器调用在指定的单个客户端上执行。函数名必须以“Target”开头。用于服务器向特定客户端发送消息如“只告诉你一个人你获得了隐藏任务”。[TargetRpc] void TargetSendPrivateMessage(NetworkConnection target, string message) { // 这段代码只在target这个连接对应的客户端上运行 Debug.Log($Private: {message}); }理解并熟练运用这几种RPC你就能处理绝大多数多人游戏中的交互逻辑了。它们构成了网络对象间通信的骨干。4. 实战构建一个基础的同步玩家控制器理论说再多不如动手写一行代码。我们现在就来创建一个最简单的玩家控制器实现移动和基本交互的同步。4.1 移动同步使用NetworkTransform对于基础的玩家移动WASD控制最简单高效的方式是使用Mirror自带的NetworkTransform组件。它会自动同步物体的位置Position、旋转Rotation并可选择是否同步缩放Scale。在你的玩家预制件上添加NetworkTransform组件。通常保持默认设置即可。你可以调整Sync Interval同步间隔来平衡精度和网络流量。默认0.1秒意味着每秒同步10次对于大多数移动游戏足够了。编写一个简单的移动脚本但关键点在于移动逻辑的判断要放在isLocalPlayer条件下。using Mirror; using UnityEngine; public class PlayerMovement : NetworkBehaviour { public float moveSpeed 5f; private CharacterController controller; // 假设使用CharacterController void Start() { controller GetComponentCharacterController(); // 可能只希望本地玩家初始化相机跟随等 if (isLocalPlayer) { Camera.main.transform.SetParent(transform); Camera.main.transform.localPosition new Vector3(0, 2, -5); } } void Update() { // 只有本地玩家控制的角色才处理输入 if (!isLocalPlayer) return; float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 move new Vector3(horizontal, 0, vertical).normalized; if (move.magnitude 0.1f) { // 在本地直接应用移动NetworkTransform会负责将位置同步出去 controller.Move(move * moveSpeed * Time.deltaTime); } } }核心逻辑if (!isLocalPlayer) return;这行代码至关重要。它确保了每个客户端只处理自己角色的输入。你的角色移动由你的客户端计算并应用NetworkTransform组件在后台将这个变换Transform信息发送给服务器服务器再广播给其他所有客户端。其他客户端上的“你”的角色其NetworkTransform组件会接收数据并更新位置从而实现同步。4.2 自定义状态同步使用[SyncVar]同步血量假设我们想同步玩家的血量Health。血量应该由服务器权威管理客户端只能查看不能直接修改。在玩家控制脚本中定义一个同步变量。public class PlayerHealth : NetworkBehaviour { [SyncVar(hook nameof(OnHealthChanged))] public int currentHealth 100; public void TakeDamage(int amount) { // 伤害逻辑必须在服务器端执行 if (!isServer) return; currentHealth - amount; if (currentHealth 0) { // 处理玩家死亡例如重生 RpcRespawn(); } } // 当SyncVar的currentHealth在服务器端发生变化时 // 这个hook函数会在所有客户端上被调用并传入新旧值。 void OnHealthChanged(int oldHealth, int newHealth) { Debug.Log($Health changed from {oldHealth} to {newHealth}); // 在这里更新客户端的UI比如血条 // UpdateHealthBar(newHealth); } [ClientRpc] void RpcRespawn() { // 所有客户端看到玩家死亡/重生效果 if (isLocalPlayer) { // 本地玩家可能需要特殊的处理比如禁用输入 transform.position Vector3.zero; // 简单重生到原点 currentHealth 100; // 注意这个赋值只在本地服务器上的SyncVar会通过hook再次同步下来 } } }在其他地方比如一个敌人的攻击脚本里调用伤害函数。// 假设在敌人脚本中检测到攻击命中玩家 void OnTriggerEnter(Collider other) { PlayerHealth playerHealth other.GetComponentPlayerHealth(); if (playerHealth ! null) { // 直接调用玩家的TakeDamage方法。 // 因为TakeDamage内部有if (!isServer) return; // 所以这个调用只有在服务器上运行的逻辑比如敌人AI在服务器端才会生效。 // 如果敌人是客户端预测的那么可能需要用[Command]将攻击事件发送到服务器验证。 playerHealth.TakeDamage(10); } }关键点[SyncVar]的hook参数非常有用。它允许你在变量同步后自动执行一个函数来更新视觉效果如UI血条、播放音效等。这比在Update里不断检查变量值要高效和准确得多。4.3 实现简单的玩家间交互[Command]与[ClientRpc]配合让我们实现一个“玩家点击鼠标向所有玩家问好”的功能。在玩家控制器脚本中添加void Update() { if (!isLocalPlayer) return; if (Input.GetMouseButtonDown(0)) { CmdSayHello(Hello from Player!); } } [Command] void CmdSayHello(string message) { Debug.Log($Server received: {message} from {connectionToClient.identity.netId}); // 服务器收到后广播给所有客户端 RpcAnnounceHello(${connectionToClient.identity.netId} says: {message}); } [ClientRpc] void RpcAnnounceHello(string announcement) { // 在所有客户端上显示这条消息 Debug.Log(announcement); // 或者更新一个全局的聊天UI // ChatUI.Instance.AddMessage(announcement); }这个流程清晰地展示了交互链条本地输入 - Cmd发送到服务器- 服务器处理 - Rpc广播给所有客户端- 所有客户端显示。5. 运行测试与基础调试代码写完了不跑起来看看怎么知道行不行。Mirror的NetworkManagerHUD让本地测试变得非常简单。确保你的场景中有NetworkManager对象带NetworkManagerHUD组件。在Unity编辑器中点击Play按钮。游戏运行时屏幕左上角会出现一个简单的网络控制UI。作为主机Host运行点击“Host (Server Client)”。这会在本地同时启动一个服务器和一个客户端合称“主机”。你相当于既是服务器又是玩家1。这是最快速的测试方式。再启动一个客户端不要停止当前运行在Unity编辑器的顶部菜单选择File-Build and Run或者使用ParrelSync等工具来构建并运行一个独立的游戏实例。在这个新运行的客户端里将Network Address设置为“localhost”点击“Client”连接。现在你应该能在两个游戏窗口里看到彼此的角色了移动一个窗口中的角色观察另一个窗口中的角色是否同步移动。观察与调试在Unity编辑器的运行窗口你可以看到Mirror打印的详细日志比如客户端连接、断开、RPC调用等。使用Debug.Log并注意输出窗口是追踪逻辑流程最基本有效的方法。可以在NetworkManager上启用“Show Debug Messages”来获得更多信息。实操心得在早期开发阶段强烈建议使用“Host”模式进行测试。因为你一个人就同时扮演了服务器和客户端可以快速验证大部分基础同步逻辑是否工作。等到需要测试客户端间差异或延迟模拟时再开多个独立客户端。6. 常见问题与避坑指南实录踩坑是学习过程中必不可少的一部分。下面这些是我和很多Mirror开发者都曾遇到过的问题希望你能提前避开。6.1 同步延迟与插值Interpolation你可能会发现其他玩家非本地控制的运动看起来一跳一跳的不流畅。这是因为NetworkTransform默认只同步位置数据包客户端收到后直接“硬塞”过去。为了解决这个问题NetworkTransform组件内置了插值Interpolation。原理客户端会缓存最近收到的几个状态更新然后在渲染帧之间根据时间差平滑地计算出物体当前应该处于的位置再进行渲染。这样即使网络更新频率如10Hz低于渲染频率如60Hz运动看起来也是平滑的。设置在NetworkTransform组件上找到Interpolate Position和Interpolate Rotation选项勾选它们。通常还需要调整Interpolation Factor插值因子它控制平滑的程度。值越大延迟感越强但越平滑值越小响应越快但可能抖动。需要根据游戏类型和网络条件做权衡。注意插值会增加显示延迟。对于需要快速反应的游戏如格斗你可能需要更复杂的预测Prediction和补偿Reconciliation算法这属于更高级的话题。6.2 “[Command] function … invoked without authority” 错误这是新手最常见的错误之一。它的意思是一个没有权威Authority的客户端试图调用一个[Command]方法。原因[Command]默认要求调用者必须拥有该网络对象即挂载脚本的那个GameObject的权威。对于玩家角色通常只有本地玩家自己才有这个权威。检查点确保调用CmdXxx方法的代码是在isLocalPlayer为true的对象上执行的。通常意味着这个脚本应该挂在玩家预制件上并且在Update或事件处理中用if (!isLocalPlayer) return;做了保护。如果你试图从一个UI按钮它不属于任何网络对象直接调用玩家身上的[Command]就会出错。正确的做法是UI按钮调用一个本地玩家脚本里的普通方法再由这个普通方法去调用[Command]。6.3 对象生成Spawn与销毁Destroy的时机在多人游戏中动态创建和销毁物体如子弹、特效、掉落物必须通过网络进行。生成使用NetworkServer.Spawn(GameObject)来在服务器上生成一个网络对象。只有通过这个方法生成的对象其NetworkIdentity才会被所有客户端识别和同步。客户端不能直接Instantiate一个需要同步的预制件。销毁使用NetworkServer.Destroy(GameObject)或NetworkServer.UnSpawn(GameObject)来在服务器上销毁或取消生成对象。这会自动在所有客户端上销毁对应的对象。客户端不能直接Destroy一个网络对象。常见场景玩家发射子弹。[Command] void CmdFire() { GameObject bullet Instantiate(bulletPrefab, firePosition.position, firePosition.rotation); bullet.GetComponentRigidbody().velocity transform.forward * bulletSpeed; // 关键步骤生成网络对象 NetworkServer.Spawn(bullet); // 设置子弹几秒后自动销毁 Destroy(bullet, 5f); // 注意这里用普通的Destroy因为Spawn后其生命周期由网络系统接管Destroy会触发网络销毁。 }6.4 SyncVar Hook不触发或触发异常Hook只在值改变时触发如果服务器端SyncVar的值被设置为和之前相同的值hook不会执行。Hook在客户端执行hook函数是在每个客户端上当接收到服务器同步下来的新值时被调用的。所以不要在hook函数里试图修改这个SyncVar本身除非你很清楚后果否则可能引起循环或意外行为。初始值不同步SyncVar的初始值在Awake/Start里赋的值不会自动从服务器同步到客户端。客户端对象在生成时SyncVar会先被设置为预制件上或脚本中定义的初始值然后立刻收到服务器发来的第一个同步值并触发hook。因此你的UI初始化逻辑应该能处理从初始值到第一次同步的过渡。6.5 网络标识netId与场景物体动态生成的物体通过NetworkServer.Spawn生成的物体会被分配一个唯一的netId。场景中的物体如果场景中已经放置了带有NetworkIdentity的物体比如地图上的门、开关你需要确保它们在所有客户端的场景中都有相同的路径和初始状态。并且你需要在NetworkManager的“Registered Spawnable Prefabs”列表里注册它们的预制件吗不需要。只有那些需要动态生成的预制件才需要注册。场景中静态放置的网络物体Mirror会自动处理它们的生成和同步只要它们的NetworkIdentity在构建场景时存在即可。确保在构建所有客户端版本时使用相同的场景文件。搭建一个稳定的多人游戏框架就像盖房子打地基。今天我们把地基的核心部分——Mirror的基本设置、同步原理、以及一个可运行的玩家控制器——给夯实了。你可能已经能让两个方块在局域网里互相追逐了。但这仅仅是开始接下来我们会面对更复杂的问题如何处理玩家的输入预测与服务器调和如何同步复杂的动画状态如何设计一个可扩展的游戏房间系统这些才是让多人游戏从“能玩”到“好玩”的关键。在下一篇里我们会深入这些话题继续用Mirror搭建更健壮的多人游戏骨架。