
1. 项目概述为什么选择Mirror开启你的多人游戏之旅如果你是一名Unity开发者并且心中一直有个念头想做一个能和朋友们一起玩的游戏那么“多人联机”这个技术门槛可能就是你面前最大的拦路虎。传统的UNETUnity旧版网络系统早已被官方弃用而像Photon这样的第三方服务虽然强大但往往伴随着复杂的配置和潜在的商业成本。正是在这种背景下Mirror横空出世成为了Unity社区中开发多人游戏的首选开源方案。它脱胎于UNET的高层APIHLAPI但经过了彻底的现代化重构性能更好、文档更全、社区支持也更活跃。简单来说Mirror让你能用最接近编写单机游戏逻辑的思维方式去构建一个稳定、可扩展的多人游戏。本教程的目标就是带你从零开始亲手搭建一个可以运行在互联网环境下的多人游戏原型让你彻底理解从网络对象同步到房间管理的完整流程。2. 核心概念与Mirror网络架构解析在动手写代码之前我们必须先理解Mirror或者说所有权威服务器架构的多人游戏的几个核心基石。这能让你在后续开发中清楚地知道每一行代码是在为哪个环节服务避免“照猫画虎却不知其所以然”的困境。2.1 服务器与客户端谁说了算这是多人游戏最根本的问题。Mirror默认采用“服务器权威”架构。这意味着服务器Server/Host是游戏世界的“唯一真相源”。所有关键的游戏逻辑比如玩家移动是否合法、子弹是否命中、分数如何计算都在服务器上运行和裁决。客户端Client是玩家看到的窗口。它负责接收服务器的状态更新并渲染出来同时将玩家的输入如按键、鼠标点击发送给服务器处理。为什么必须是服务器权威想象一下如果每个客户端都可以直接告诉其他客户端“我打中你了”那么作弊将无法防止。一个修改了本地内存的玩家可以宣称自己每秒攻击100次。而在服务器权威下客户端只是说“我想攻击”最终由服务器计算冷却时间、判断命中再将结果同步给所有人。这保证了游戏的公平性。2.2 NetworkIdentity与NetworkBehaviour网络对象的身份证和行为脚本在Mirror中不是每个GameObject都能在网络间同步。它必须挂载NetworkIdentity组件。这个组件就像是该物体在网络世界中的唯一身份证包含了其在服务器上的网络IDNetId。而NetworkBehaviour则是继承自MonoBehaviour的特殊脚本基类。只有继承自它的脚本才能使用Mirror提供的网络功能特性例如[SyncVar]同步变量。当服务器上这个变量的值改变时会自动同步到所有客户端。[Command]命令。这是一个从客户端实例发起但在服务器实例上执行的函数。[ClientRpc]客户端远程过程调用。这是一个从服务器实例发起在所有客户端实例上执行的函数。[TargetRpc]目标远程过程调用。从服务器发起仅在一个特定的客户端连接上执行。理解这三者Command, ClientRpc, TargetRpc的调用方向和执行位置是掌握Mirror编程模型的关键。2.3 生成与同步物体如何出现在所有人的屏幕上在单机游戏中我们用Instantiate创建物体。在Mirror网络中必须使用NetworkServer.Spawn方法。这个方法会在服务器上创建物体并自动将该物体的生成信息发送给所有客户端客户端随后会在本地实例化一个对应的物体。同理销毁物体要使用NetworkServer.Destroy。物体的状态位置、旋转、血量等同步Mirror提供了几种方式同步变量SyncVar适用于离散的状态变化如血量、分数、状态标志。效率高但更新不是每帧进行的。同步变换组件NetworkTransformMirror内置组件用于自动同步物体的位置、旋转和缩放。对于玩家角色我们通常使用它。自定义同步OnSerialize/OnDeserialize对于复杂的、需要高效压缩的状态如一堆物体的位置可以重写这两个方法进行底层同步但门槛较高。3. 从零搭建一个可移动、可射击的多人游戏原型理论说得再多不如动手做一遍。我们将创建一个最简单的多人游戏场景一个三维平面作为地板玩家可以控制一个胶囊体移动并且可以发射小球进行“攻击”。3.1 项目初始化与Mirror导入创建新项目打开Unity Hub创建一个新的3D核心项目。导入Mirror最推荐的方式是通过Unity的Package Manager从Git URL添加。在Window - Package Manager中点击“”号选择“Add package from git URL”输入https://github.com/vis2k/Mirror.git。这种方式能确保你获取到最新的稳定版本。等待导入完成。3.2 创建玩家预制体与网络控制器创建玩家预制体在场景中创建一个Capsule胶囊体命名为“Player”。为其添加CharacterController组件以便移动。关键一步添加NetworkIdentity组件。将其 “Local Player Authority” 勾选上。这表示这个玩家对象将由对应的客户端控制例如移动输入。将其拖入Project窗口的Resources文件夹如果没有就创建一个生成预制体。然后删除场景中的实例。编写玩家移动脚本创建一个名为PlayerMovement的C#脚本继承自NetworkBehaviour。using Mirror; using UnityEngine; public class PlayerMovement : NetworkBehaviour { public float moveSpeed 5f; public float turnSpeed 180f; private CharacterController controller; void Start() { controller GetComponentCharacterController(); // 关键如果不是本地玩家控制的物体则禁用这个脚本 // 这样只有你自己控制你的角色其他玩家的角色由网络同步位置不受你的输入影响 if (!isLocalPlayer) { // 可以禁用摄像机、输入等这里我们禁用整个脚本 // 通常我们会保留脚本但禁用控制部分为了演示简单直接禁用 // 更优做法是禁用摄像机和控制逻辑保留脚本用于接收网络同步 // 本例中我们先简单处理 enabled false; return; } // 如果是本地玩家可以在这里设置跟随摄像机等 SetupLocalPlayer(); } void SetupLocalPlayer() { // 例如找到一个摄像机将其设置为跟随此玩家 Camera.main.transform.SetParent(transform); Camera.main.transform.localPosition new Vector3(0, 2, -5); Camera.main.transform.LookAt(transform); } void Update() { if (!isLocalPlayer) return; // 再次检查确保只有本地玩家执行输入 float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 moveDirection new Vector3(horizontal, 0, vertical); moveDirection transform.TransformDirection(moveDirection); // 将输入转换为世界方向 moveDirection * moveSpeed; // 简单重力模拟 if (!controller.isGrounded) { moveDirection.y Physics.gravity.y * Time.deltaTime; } controller.Move(moveDirection * Time.deltaTime); // 鼠标控制旋转 float turn Input.GetAxis(Mouse X) * turnSpeed * Time.deltaTime; transform.Rotate(0, turn, 0); } }将脚本挂载到Player预制体上。处理玩家生成我们需要一个脚本来告诉Mirror当玩家连接时生成哪个预制体。创建一个名为NetworkRoomManager的脚本Mirror也内置了更复杂的RoomManager这里我们从简。using Mirror; using UnityEngine; public class MyNetworkManager : NetworkManager { public override void OnServerAddPlayer(NetworkConnectionToClient conn) { base.OnServerAddPlayer(conn); // 当服务器为连接添加玩家时base方法会从playerPrefab生成玩家 // 你可以在这里进行额外的初始化比如设置玩家名字、队伍等 Debug.Log($玩家 {conn.connectionId} 已加入游戏。); } }在场景中创建一个空的GameObject命名为“NetworkManager”。将MyNetworkManager脚本挂上去同时它也会自动添加NetworkManager组件。在Inspector中将之前创建的Player预制体拖拽到NetworkManager组件的 “Player Prefab” 槽位中。3.3 实现网络同步射击功能现在让玩家可以发射“子弹”。创建子弹预制体创建一个Sphere球体缩小比例如0.3命名为“Bullet”。添加Rigidbody组件取消勾选“Use Gravity”防止它下落。添加NetworkIdentity组件。创建一个名为Bullet的脚本继承NetworkBehaviour用于处理飞行和碰撞。using Mirror; using UnityEngine; public class Bullet : NetworkBehaviour { public float speed 20f; public float lifeTime 3f; // 子弹存活时间避免一直存在 private Rigidbody rb; void Start() { rb GetComponentRigidbody(); if (isServer) { // 只有服务器需要处理物理和销毁 rb.velocity transform.forward * speed; Invoke(nameof(DestroySelf), lifeTime); } } [ServerCallback] // 确保此方法只在服务器调用 void OnTriggerEnter(Collider other) { // 服务器判断碰撞 if (other.CompareTag(Player)) { Debug.Log($子弹击中了玩家: {other.name}); // 这里可以处理伤害逻辑例如调用其他玩家脚本上的[Command]来扣血 // 例如other.GetComponentPlayerHealth().TakeDamage(10); } // 碰撞后销毁子弹 DestroySelf(); } [Server] void DestroySelf() { NetworkServer.Destroy(gameObject); } }将脚本挂到Bullet上将其做成预制体并从场景中删除。修改PlayerMovement脚本添加射击功能 在PlayerMovement脚本中增加以下代码public GameObject bulletPrefab; // 在Inspector中分配Bullet预制体 public Transform firePoint; // 创建一个空子物体作为发射点并拖拽至此 void Update() { // ... 原有的移动代码 ... if (Input.GetButtonDown(Fire1)) // 鼠标左键 { CmdFire(); } } [Command] // 这个方法在客户端调用在服务器执行 void CmdFire() { // 在服务器端实例化子弹 GameObject bullet Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); // 在网络上生成这个子弹所有客户端都会看到 NetworkServer.Spawn(bullet, connectionToClient); // connectionToClient 将此子弹的生成权关联给发射的客户端 }回到Player预制体创建一个空子物体放在胶囊体前方命名为“FirePoint”并拖拽到脚本的firePoint字段。将Bullet预制体拖拽到脚本的bulletPrefab字段。关键点CmdFire方法必须以前缀Cmd开头。这是Mirror的约定用于标识这是一个Command。3.4 运行与测试局域网内的首次联机构建设置File - Build Settings将当前场景添加到Scenes In Build。测试方法一编辑器内测试这是Mirror最强大的功能之一。在Unity编辑器中点击播放按钮。在Game视图左上角你会看到“Network Manager HUD”组件如果没有请给NetworkManager物体添加NetworkManagerHUD组件。你可以选择“Host (Server Client)”作为主机同时运行服务器和第一个客户端然后单独构建一个游戏程序作为第二个客户端连接进来。方法二构建后测试构建一个可执行文件如.exe。先运行一个实例点击“Host”。再运行第二个实例点击“Client”在地址栏输入localhost或127.0.0.1进行连接。观察当两个客户端连接后你会看到彼此的角色。控制其中一个移动、旋转、射击观察另一个客户端屏幕上的同步效果。子弹由服务器生成并同步给所有客户端。注意在编辑器内同时运行服务器和客户端时它们的输入如WSAD会冲突。一个简单的解决办法是在PlayerMovement的Start方法里为本地玩家禁用其他客户端的角色控制脚本就像我们上面做的那样。更专业的做法是使用Input.GetAxisRaw并配合自定义输入管理。4. 超越局域网部署到互联网的实战方案在本地测试成功只是万里长征第一步。如何让不在同一个Wi-Fi下的朋友也能连进来这就需要我们解决“非局域网联机”的核心问题网络地址转换NAT穿透和公网IP发现。4.1 方案选型转发服务器 vs. 中继服务器 vs. 纯P2P对于小团队或独立开发者通常有以下几种选择端口转发Port Forwarding让主机玩家在自家路由器上设置端口转发如7777端口转发到主机电脑。其他玩家通过主机的公网IP地址连接。不推荐因为操作复杂、不安全、且很多家庭网络无法获取真实公网IP处于运营商大内网中。使用中继服务器Relay Server这是Mirror社区最主流、最推荐的方案。所有客户端都连接到一个拥有公网IP和固定地址的第三方服务器数据通过这个服务器进行转发。它完美解决了NAT穿透问题。Mirror官方Telepathy传输层 第三方中继服务Mirror默认的传输层是Telepathy它是一个简单的TCP传输层。你可以将它用于客户端与你自己搭建的专用游戏服务器通信但这需要你有一台云服务器。4.2 使用Steamworks.NET与Facepunch.Steamworks进行中继推荐对于想快速上线的独立游戏利用Steam平台已有的网络基础设施是最佳选择。Steam提供了强大的Steamworks API其中就包括P2P网络中继服务SteamNetworkingSockets。实现思路获取Steamworks.NET在Unity Asset Store购买或下载免费的Steamworks.NET插件。同时你还需要一个Steam游戏AppID可以在Steamworks后台创建。集成Facepunch.Steamworks传输层Mirror社区有一个非常优秀的第三方传输层实现叫做Mirror.Facepunch或类似名称的包。它用Steam的P2P网络替代了Mirror默认的Telepathy。配置与连接在NetworkManager上将Transport组件替换为Steamworks传输层。初始化Steamworks API需要AppID。客户端连接时不再使用IP地址而是使用对方的SteamID。服务器主机将自己的SteamID通过其他方式如游戏大厅告知其他玩家其他玩家通过此SteamID发起P2P连接。Steam后台会自动协商最佳路径如果直连失败则会通过Steam的中继服务器进行转发。优点免费Steam为中继流量提供了非常慷慨的免费额度对于中小型游戏完全够用。稳定可靠依托Steam全球服务器网络连接成功率高延迟可控。自带好友/大厅系统可以方便地集成Steam的好友列表和游戏大厅邀请功能。缺点游戏必须通过Steam发行和启动。需要一定的Steamworks API集成学习成本。4.3 使用第三方网络服务如Nakama、PlayFab这些是Backend-as-a-ServiceBaaS平台提供了包括用户认证、数据库、实时多人匹配、中继服务器等一整套后端服务。你只需要调用它们的SDK。优点功能全面节省大量后端开发时间。通常提供免费层适合原型开发。有专业的运维和扩展性保障。缺点有成本当用户量增长后费用可能显著增加。游戏逻辑与特定服务商绑定迁移成本高。4.4 自建中继服务器高阶方案如果你需要完全的控制权或者游戏有特殊的网络协议需求可以自己用C#或其他语言编写一个简单的中继服务器。Mirror的传输层是可插拔的你可以实现自己的Transport类让客户端都连接到你用.NET Core或Node.js搭建的中央服务器该服务器只负责转发数据包不处理游戏逻辑与权威游戏服务器区分开。技术栈选择.NET Core KCP使用KCP这个快速可靠的ARQ协议在UDP上实现类TCP的可靠传输但延迟更低。有现成的C#库如KCPSharp。Node.js WebSocket对于回合制或实时性要求不高的游戏WebSocket是更简单的选择。实操心得对于你的第一个多人游戏项目我强烈推荐从Steamworks Facepunch传输层方案开始。它虽然需要集成Steam但能一劳永逸地解决联机问题让你专注于游戏玩法开发而不是陷在网络泥潭里。在项目早期你可以先用局域网测试功能等核心玩法验证完毕再花几天时间集成Steam联机整个流程会顺畅很多。5. 性能优化与同步策略深度剖析当你的游戏里有了几十个玩家和上百个子弹、特效时网络带宽和同步效率就成了瓶颈。Mirror提供了一些工具但如何用好它们需要技巧。5.1 同步频率与网络可视化Mirror的NetworkManager中有Send Rate发送频率默认每秒30次和Sync Interval同步间隔等参数。不要盲目提高发送频率这会导致流量暴增。使用Network ProfilerWindow - Analysis - Network Profiler。这是你优化网络性能最重要的工具。它能实时显示每个网络对象NetworkIdentity的流量占用帮你找到“流量大户”。差异化同步对于静止的背景物体不需要同步。对于移动缓慢的NPC可以降低其NetworkTransform的同步频率。对于高速运动的子弹可能需要更高的频率。5.2 状态同步 vs. 输入同步这是网络游戏设计的哲学问题Mirror主要支持状态同步但我们也可以混合使用。状态同步服务器将游戏状态位置、血量同步给客户端。优点是逻辑简单客户端预测和补偿容易实现Mirror的NetworkTransform带有简单的插值功能。缺点是带宽消耗随对象数量线性增长。输入同步也称为确定性锁步客户端只将操作输入发送给服务器服务器运行相同的确定性逻辑然后将“关键帧”状态或事件发送给客户端。优点是带宽极低且完全防作弊。缺点是实现复杂所有逻辑必须是确定性的且对延迟更敏感。对于大多数动作类游戏采用“客户端预测服务器权威状态同步”的混合模式是主流。例如玩家移动客户端立即响应输入移动本地角色预测同时将输入发送给服务器。服务器验证后计算最终位置再同步回来。如果客户端预测的位置与服务器最终位置有差异需要进行平滑纠正插值或回滚。Mirror的NetworkTransform带有基本的插值但对于快节奏游戏你可能需要自己实现更复杂的预测算法。射击判定必须由服务器进行射线检测Raycast。客户端可以播放射击动画和特效预测但命中结果必须等待服务器验证后通过[ClientRpc]通知所有客户端播放命中特效。5.3 网络对象的池化管理频繁地使用NetworkServer.Spawn和NetworkServer.Destroy子弹、特效等物体会产生大量的网络消息和GC垃圾回收压力。解决方案实现一个简单的网络对象池。游戏开始时服务器使用Instantiate创建一批子弹预制体然后用NetworkServer.Spawn生成它们但立即将它们SetActive(false)并存入一个队列。当需要发射子弹时从池中取出一个SetActive(true)设置其位置和速度然后调用NetworkServer.Spawn的变体NetworkServer.Spawn(Object, NetworkConnection)来“重新激活”它实际上Mirror的Spawn会处理激活状态你需要自己管理对象池的激活/禁用逻辑可能需配合自定义生成处理。子弹命中或超时后服务器将其SetActive(false)并放回池中而不是销毁。注意事项Mirror对对象池的支持需要小心处理因为NetworkIdentity的NetId是持久的。一个常见的做法是池中的对象生成后就不销毁而是通过脚本控制其“回收”和“重用”在重用前手动重置其状态如位置、速度、血量。你需要重写OnStartServer和OnStopServer来管理池中对象的生命周期。6. 常见问题排查与调试技巧实录开发过程中你一定会遇到各种诡异的网络问题。这里记录一些典型坑位和排查手段。6.1 连接失败类问题问题现象可能原因排查步骤客户端无法连接到主机非局域网1. 主机防火墙/路由器未开放端口。2. 主机处于多层NAT后如校园网、公司网。3. 网络地址填写错误。1.局域网测试先用localhost或内网IP测试确保游戏本身无问题。2.检查端口确认NetworkManager中设置的端口默认7777已在主机防火墙和路由器中做转发TCPUDP。3.使用中继如果无法进行端口转发果断采用Steam中继等方案。连接后立即断开1. 客户端与服务器的游戏版本特别是预制体、脚本结构不一致。2.NetworkIdentity的AssetId冲突或为空。1.检查预制体确保服务器和客户端工程中的关键预制体Player, Bullet是完全相同的且NetworkIdentity组件的AssetId是有效的GUID通常保存预制体后会自动生成。2.检查脚本确保所有NetworkBehaviour脚本的命名空间、类名、变量顺序完全一致。只有主机能看到其他玩家玩家预制体上的NetworkTransform组件缺失或配置错误。1. 确保玩家预制体上有NetworkTransform组件。2. 检查其Sync Mode设置对于玩家角色通常使用Sync to Owner或Sync to Observers。6.2 同步与逻辑问题问题现象可能原因排查步骤其他玩家的移动卡顿、跳跃网络延迟或丢包且同步插值设置不当。1. 调整NetworkTransform上的Interpolation和Extrapolation参数。2. 在PlayerMovement脚本中为非本地玩家禁用CharacterController或Rigidbody的物理控制仅依赖网络同步的位置更新。[Command]或[ClientRpc]不执行1. 函数命名不符合约定CmdXXX, RpcXXX。2. 在错误的端调用如在非本地客户端调用Cmd。3. 参数类型不被序列化支持。1.检查命名Command必须以Cmd开头ClientRpc必须以Rpc开头。2.检查调用端使用isLocalPlayer,isServer,isClientOnly等属性进行保护。3.检查参数确保参数是Mirror支持的基本类型或自定义的可序列化结构体。子弹等物体生成位置错误生成位置firePoint的变换未正确同步或在Command中使用了本地变换。确保firePoint是网络对象Player的子物体其位置会随父物体同步。在CmdFire中firePoint.position和rotation已经是服务器上该物体的正确世界坐标。6.3 调试利器自定义日志与断点Mirror日志在Edit - Project Settings - Mirror可以设置详细的日志级别。遇到诡异问题时打开Developer或Info级别日志能帮你追踪每一个网络消息。条件编译与自定义日志void CmdFire() { #if UNITY_SERVER || UNITY_EDITOR Debug.Log($[Server] Player {netId} fired a bullet.); #endif // ... 生成子弹逻辑 ... }在编辑器中模拟多客户端利用Mirror的NetworkManagerHUD的“Server Only”模式和多个游戏实例进行调试。或者编写测试脚本用代码模拟多个客户端连接和操作。开发多人游戏是一个不断与延迟、丢包和状态一致性作斗争的过程。Mirror为你提供了强大的武器但如何运用得当还需要你在实际项目中不断积累经验。从这个小原型出发逐步添加房间系统、玩家状态、更复杂的同步对象你会发现那个曾经看似遥不可及的多人游戏梦想正在你的手中一步步变为现实。