ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity NetCode双人移动对战原型:从零搭建服务器权威同步与客户端预测

Unity NetCode双人移动对战原型:从零搭建服务器权威同步与客户端预测 1. 项目概述与核心价值最近在社区里看到不少朋友对Unity的联机开发感兴趣尤其是Unity官方推出的NetCode for GameObjects简称NetCode这套方案。很多教程要么讲得太浅只告诉你拖几个组件要么直接上复杂的权威服务器架构对新手不太友好。所以我想从一个更务实的角度出发分享如何用一下午的时间从零开始搭建一个双人移动对战原型。这个原型的目标非常明确两个玩家可以加入同一个游戏房间分别控制自己的角色在共享的场景中自由移动并能实时看到对方的移动状态。别看它简单这恰恰是绝大多数联机对战游戏从《糖豆人》到《永劫无间》最核心、最基础的地基。掌握了这个原型的搭建过程你就能理解客户端预测、服务器调和、网络变量这些听起来高大上的概念到底是如何在代码里落地的。我选择NetCode for GameObjects而不是Mirror或Photon主要是因为它与Unity引擎的集成度最高学习路径相对平滑并且代表了Unity官方在联机领域的未来方向。对于刚接触联机的新手来说先理解这套“官方钦定”的流程再去看其他方案会更有章法。整个原型我们将使用NetCode的Client-Server模式这是最经典也最易于理解的架构。下面我们就从环境准备开始一步步把这个原型“跑起来”。2. 环境准备与项目初始化2.1 Unity版本与必要包安装工欲善其事必先利其器。首先确保你使用的Unity版本是2021.3 LTS或更新版本我个人使用的是2022.3 LTS长期支持版比较稳定。打开Unity Hub创建一个新的3D核心模板项目命名为“NetCodeDualMovementDemo”。项目创建好后我们需要通过Package Manager安装几个核心的包。打开Window - Package Manager确保左上角来源是“Unity Registry”。然后搜索并安装以下包NetCode for GameObjects: 这是我们的主角版本选择最新的稳定版如1.0.0。Multiplayer Tools: 一个辅助工具集包含一些有用的调试和构建工具。Unity Transport Package: NetCode底层使用的网络传输层通常安装NetCode时会自动依赖安装但最好确认一下。安装完成后Unity可能会要求重启编辑器照做即可。重启后检查Edit - Project Settings - NetCode for GameObjects确认NetCode已成功启用。2.2 基础场景搭建与网络管理器接下来搭建一个最简单的测试场景。在场景中创建一个平面Plane作为地面调整缩放至合适大小。创建两个不同颜色的胶囊体Capsule分别命名为Player_Red和Player_Blue作为两个玩家的视觉代表。记得为它们添加刚体Rigidbody组件并勾选“Is Kinematic”因为我们后续会用代码控制移动不希望受到物理引擎的干扰。联机游戏的核心是网络管理器NetworkManager。在Hierarchy中右键选择“NetCode” - “Create NetCode Starter”Unity会自动为你创建一个名为“NetworkManager”的GameObject。这个对象至关重要它包含了NetworkManager组件和Unity Transport组件负责管理整个游戏的生命周期启动服务器、客户端连接、场景同步等。注意不要随意删除或重命名这个自动创建的NetworkManager对象。它的预制体配置是NetCode框架所依赖的。如果你想自定义UI可以通过代码引用它的组件而不是直接修改其结构。3. 核心网络实体与玩家预制体创建3.1 理解NetworkObject与NetworkTransform在NetCode中任何需要在网络上同步的GameObject都必须挂载NetworkObject组件。这个组件赋予了一个游戏对象在网络中的唯一身份标识NetworkId。而NetworkTransform组件则负责将这个对象的Transform位置、旋转信息在服务器和客户端之间同步。我们的玩家胶囊体就需要这两个组件。但最佳实践不是直接给场景中的对象添加而是先创建玩家预制体Prefab。将Player_Red拖入Project窗口的Assets文件夹创建一个预制体然后删除场景中的原始对象。打开这个预制体为其依次添加以下组件NetworkObjectNetworkTransform在NetworkTransform组件中保持默认设置即可。它已经帮我们处理了基础的插值平滑移动和状态同步。3.2 配置连接与玩家生成接下来我们需要告诉NetworkManager“当有客户端连接时请生成这个玩家预制体作为他的代表。” 这需要通过网络配置NetworkConfig来完成。选中Hierarchy中的NetworkManager对象在Inspector中找到NetworkManager组件。其中有一个“Player Prefab”的字段。将我们刚刚创建的玩家预制体拖拽赋值给它。这样每当一个新客户端连接成功服务器就会在默认位置或你指定的位置为这个客户端实例化一个该预制体的副本。现在我们还需要一种方式来启动服务器和客户端。一个简单的方法是使用NetCode Tools提供的“PlayMode Tools”。在顶部菜单栏找到“Multiplayer” - “PlayMode Tools”会弹出一个窗口。在这里你可以方便地选择以“Server”、“Client”或“Server Client”模式运行编辑器。为了测试我们通常先以“Server Client”模式运行这样编辑器同时扮演服务器和第一个客户端便于快速调试。4. 实现玩家移动与输入同步4.1 创建玩家移动脚本移动是游戏交互的基础。我们需要创建一个脚本既能处理本地玩家的输入又能将移动指令发送到服务器并由服务器权威地计算和同步结果。在Project窗口中创建Scripts文件夹新建一个C#脚本命名为PlayerMovement。using Unity.Netcode; using UnityEngine; public class PlayerMovement : NetworkBehaviour { [SerializeField] private float moveSpeed 5f; private void Update() { // 只有本地玩家控制的角色才处理输入 if (!IsOwner) return; float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 movement new Vector3(horizontal, 0, vertical) * moveSpeed * Time.deltaTime; transform.Translate(movement, Space.World); } }将这段脚本挂载到你的玩家预制体上。这里有几个关键点继承自NetworkBehaviour这是所有NetCode网络脚本的基类提供了IsOwner、IsServer等关键属性。IsOwner判断IsOwner用于判断当前脚本实例所挂载的游戏对象是否属于本地客户端所控制的玩家。这行代码确保了只有“我”控制的角色才会响应“我”的键盘输入。其他玩家控制的角色其IsOwner为false因此不会执行移动逻辑它们的移动将由网络同步数据来驱动。简单的Translate移动目前我们使用Transform.Translate进行移动这在单机模式下没问题但在联机模式下会立即带来一个严重问题客户端权威移动。因为移动计算直接发生在客户端服务器没有参与其他客户端看到的这个玩家的位置是来自第一个客户端网络同步的位置数据。这会导致作弊、位置不一致等诸多问题。4.2 引入客户端预测与服务器权威移动为了解决上述问题我们必须采用服务器权威Server-Authoritative架构。核心思想是客户端只负责发送输入指令Input服务器接收所有客户端的指令进行统一的游戏逻辑计算包括移动然后将结果状态位置广播给所有客户端。客户端在等待服务器响应的同时可以基于自己发送的输入先进行“预测”移动等收到服务器的权威状态后再进行“调和”。修改PlayerMovement脚本实现一个基础的客户端预测和服务器权威移动模型using Unity.Netcode; using UnityEngine; public class PlayerMovement : NetworkBehaviour { [SerializeField] private float moveSpeed 5f; // 用于存储每帧的输入 private Vector2 _inputVector; private void Update() { if (!IsOwner) return; // 1. 本地收集输入 _inputVector.x Input.GetAxis(Horizontal); _inputVector.y Input.GetAxis(Vertical); // 2. 客户端预测立即根据输入移动本地立即响应 MovePlayer(_inputVector); // 3. 将输入发送给服务器进行权威计算 SubmitInputServerRpc(_inputVector); } // 本地移动方法 private void MovePlayer(Vector2 input) { Vector3 movement new Vector3(input.x, 0, input.y) * moveSpeed * Time.deltaTime; transform.Translate(movement, Space.World); } // 标记为ServerRpc客户端调用在服务器上执行 [ServerRpc] private void SubmitInputServerRpc(Vector2 input) { // 4. 服务器进行权威移动计算 Vector3 movement new Vector3(input.x, 0, input.y) * moveSpeed * Time.deltaTime; transform.Translate(movement, Space.World); // 5. 服务器计算后状态通过NetworkTransform会自动同步给所有客户端 // 如果客户端预测的位置与服务器权威位置有微小差异NetworkTransform的插值会平滑地修正 } }这个版本引入了ServerRpc。[ServerRpc]标记的方法可以由客户端调用但实际执行逻辑在服务器上。流程变成了客户端A按下按键在Update中收集_inputVector。客户端A立即调用MovePlayer进行客户端预测移动角色立刻开始移动体验流畅。同时客户端A通过SubmitInputServerRpc将输入发送给服务器。服务器收到Rpc在服务器端的这个玩家对象上执行SubmitInputServerRpc方法进行权威移动计算。服务器上玩家对象的位置因为移动发生了改变。由于该对象有NetworkTransform组件这个新的位置会自动同步给所有客户端包括客户端A自己。客户端A收到服务器同步过来的新位置。如果这个位置与客户端自己预测的位置有细微差别由于网络延迟NetworkTransform会利用插值Interpolation平滑地将角色修正到服务器的权威位置。这个修正通常非常细微玩家几乎感知不到但保证了所有玩家看到的位置最终是一致的。实操心得这是一个最简化的预测-权威模型。在实际项目中为了处理更高的延迟和丢包你需要引入输入缓冲、状态快照与调和Snapshot Interpolation、甚至是服务器回滚Server-side Rewind等更复杂的机制。NetCode提供了一些内置支持但理解这个基础流程至关重要。5. 完善连接与玩家识别5.1 使用NetworkVariable区分玩家目前两个玩家的预制体是一样的我们无法区分谁是谁。我们可以使用NetworkVariable来同步一些自定义数据比如玩家颜色或ID。NetworkVariable是一种特殊类型其值的变化会在服务器和客户端之间自动同步。修改脚本为玩家添加一个颜色属性using Unity.Netcode; using UnityEngine; public class PlayerMovement : NetworkBehaviour { [SerializeField] private float moveSpeed 5f; [SerializeField] private Renderer playerRenderer; // 拖入胶囊体的MeshRenderer // 定义一个网络同步的颜色变量 private NetworkVariableColor _playerColor new NetworkVariableColor(Color.white, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private Vector2 _inputVector; // 当网络生成这个对象时调用服务器和客户端都会调用 public override void OnNetworkSpawn() { base.OnNetworkSpawn(); // 如果是服务器在生成时随机分配一个颜色 if (IsServer) { _playerColor.Value Random.ColorHSV(0f, 1f, 0.8f, 1f, 0.8f, 1f); } // 订阅颜色值变化事件 _playerColor.OnValueChanged OnColorChanged; // 立即应用一次当前颜色 if (playerRenderer ! null) playerRenderer.material.color _playerColor.Value; } public override void OnNetworkDespawn() { base.OnNetworkDespawn(); _playerColor.OnValueChanged - OnColorChanged; } private void OnColorChanged(Color oldColor, Color newColor) { // 当颜色同步过来时更新渲染器 if (playerRenderer ! null) playerRenderer.material.color newColor; } // ... Update, MovePlayer, ServerRpc 等方法保持不变 ... }这段代码做了几件事声明了一个NetworkVariableColor类型的_playerColor。其写入权限WritePermission设置为Server意味着只有服务器可以修改它的值。所有客户端都有读取权限。在OnNetworkSpawn生命周期方法中如果当前是服务器IsServer就为这个变量随机赋值一个鲜艳的颜色。订阅_playerColor.OnValueChanged事件。无论这个值是在服务器修改后同步下来的还是本地刚刚获取到初始值只要值发生变化就会触发OnColorChanged回调在回调中更新玩家渲染器的颜色。这样每个玩家连接时服务器会为其分配一个随机颜色并自动同步给所有客户端实现了简单的玩家区分。5.2 构建与独立客户端测试到目前为止我们都在编辑器内使用“PlayMode Tools”进行测试。要真正模拟两个独立玩家连接我们需要构建出独立的客户端程序。构建服务器Server Build打开File - Build Settings。在“Platform”列表中选择你的目标平台如Windows。关键步骤在左下角将“Server Build”复选框勾选上。然后点击“Build”选择一个文件夹例如Builds/Server生成一个.exe文件。这个程序运行时将只作为服务器。构建客户端Client Build回到Build Settings取消勾选“Server Build”。点击“Build”选择另一个文件夹例如Builds/Client生成客户端程序。你可以将这个客户端程序复制多份以模拟多个玩家。测试流程双击运行Server.exe。它会启动一个无界面的服务器等待客户端连接。双击运行第一个Client.exe。它会尝试连接本地服务器127.0.0.1并生成玩家1红色。再双击运行第二个Client.exe。它会连接同一个服务器并生成玩家2蓝色或其他随机颜色。现在你可以分别操作两个客户端窗口观察两个角色的移动是否都能实时同步到对方窗口中。6. 常见问题与排查技巧实录在搭建和测试这个原型的过程中你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法整理出来希望能帮你节省大量时间。6.1 连接失败与超时问题描述客户端无法连接到服务器日志显示超时或连接被拒绝。排查步骤检查地址与端口默认情况下Unity Transport使用UDP监听端口是7777。确保客户端连接的地址如127.0.0.1和端口与服务器一致。你可以在NetworkManager的Unity Transport组件中查看和修改连接地址Connection Data。防火墙拦截如果是在局域网内不同机器测试确保Windows防火墙或杀毒软件没有阻止Unity构建的程序或对应的端口UDP 7777。服务器未启动最基础但最容易忽略的问题。确保服务器程序先于客户端启动并处于运行状态。检查日志服务器和客户端的输出日志Console是首要排查点。NetCode和Transport会输出详细的错误信息。6.2 移动不同步或抖动问题描述一个客户端移动另一个客户端看到的位置更新不及时、一跳一跳的或者两个客户端看到的位置不一致。原因与解决未使用NetworkTransform确保玩家预制体上挂载了NetworkTransform组件。没有它Transform无法同步。NetworkTransform参数检查NetworkTransform组件的“Interpolate”是否开启。开启后客户端会根据服务器发来的历史状态进行插值计算使移动更平滑。对于快速移动的物体可以适当降低“Interpolation Time” (RTT Max)。在Update中直接修改Transform这是大忌。我们的移动逻辑必须通过NetworkTransform同步或者通过修改Rigidbody如果使用物理并由NetworkRigidbody同步。避免在Update或FixedUpdate中直接使用transform.position ...除非是服务器权威设置初始位置。我们的脚本中预测移动使用了Translate这会在本地立即生效但最终会被服务器的权威同步覆盖和修正对于原型演示可以接受但正式项目需要更严谨的处理。网络延迟这是物理规律。高延迟下不同步感会加剧。我们的预测-权威模型就是为了缓解这个问题。如果抖动严重可以尝试在服务器和客户端使用相同的固定时间步长进行移动计算并确保Time.deltaTime的使用是一致的。6.3 玩家生成位置重叠或错误问题描述新玩家连接后生成在了地图原点0,0,0或者和已有玩家重叠在一起。解决方案NetCode提供了玩家生成处理机制。你可以创建一个脚本挂载到NetworkManager或一个空对象上并实现INetworkPrefabInstanceHandler接口。在这个接口中你可以自定义生成逻辑例如从一个预设的出生点列表中按顺序或随机选取位置来实例化玩家预制体。对于原型一个简单的办法是在服务器端的OnNetworkSpawn中判断IsServer随机设置一个出生位置transform.position new Vector3(Random.Range(-5,5), 0, Random.Range(-5,5));。6.4 ServerRpc或ClientRpc调用失败问题描述代码中的[ServerRpc]或[ClientRpc]方法没有被调用或者调用后没效果。排查要点命名规范NetCode要求Rpc方法名必须以...ServerRpc或...ClientRpc结尾。这是硬性规定否则不会被识别。权限问题ServerRpc只能由客户端对象调用即IsOwner为true的对象且在服务器上执行。ClientRpc通常由服务器调用IsServer为true广播给所有或特定客户端。参数类型限制Rpc方法的参数必须是 NetworkSerializable 的类型。基本数据类型int, float, bool, string、Unity基础类型Vector3, Quaternion和一些内置结构体都是支持的。自定义类或结构体需要实现INetworkSerializable接口。网络对象状态确保调用Rpc的NetworkBehaviour脚本所挂载的NetworkObject已经生成IsSpawned为true。6.5 构建后运行无响应或黑屏问题描述构建出的.exe文件双击后窗口黑屏、卡住或者没有任何反应。可能原因图形API或分辨率问题对于非常简单的原型可能是默认图形设置问题。尝试在Player Settings (Edit - Project Settings - Player) 中将“Resolution and Presentation”下的“Fullscreen Mode”改为“Windowed”并设置一个默认窗口大小。杀毒软件误报一些杀毒软件可能会拦截新生成的未签名.exe文件。尝试将构建输出目录添加到杀毒软件的信任区。依赖项缺失确保构建时包含了所有场景。在Build Settings的“Scenes In Build”列表中添加当前活动场景。脚本编译错误构建过程不会阻止存在编译警告的项目但如果有编译错误构建会失败。确保在构建前编辑器Console窗口没有任何错误红色消息。7. 性能优化与扩展方向完成基础原型后你可以从以下几个方向深化让它更接近一个真正的可玩原型。7.1 网络流量优化目前的移动同步NetworkTransform默认以较高的频率同步位置和旋转。对于简单的移动我们可以优化降低同步频率在NetworkTransform组件上调整“Network Tick Rate”。降低这个值如从默认的60降到30可以减少网络更新频率节省带宽但对快速移动的物体可能不适用。使用快照同步对于非关键或变化缓慢的状态可以使用NetworkVariable而不是每帧同步的Transform。我们的颜色同步就是一个例子。精简Rpc调用避免在每一帧的Update中都调用ServerRpc。可以累积输入在FixedUpdate中以固定时间间隔发送或者只在输入发生变化时发送。7.2 输入处理与命令缓冲我们当前的输入处理非常基础。一个更健壮的方案是使用NetCode的ICommand接口或INetworkSerializable来定义输入命令结构体。将每帧的输入打包成一个命令通过ServerRpc发送给服务器。服务器按接收顺序处理命令队列实现更公平和一致的权威模拟。同时客户端本地维护一个命令缓冲区用于预测和错误调和。7.3 增加基础对战元素在移动同步稳定的基础上可以逐步增加游戏性功能同步动画状态为玩家预制体添加Animator控制器和NetworkAnimator组件。通过NetworkVariable同步一个代表动作状态的整数或枚举如 idle, run, jump然后在客户端驱动动画状态机。发射子弹创建一个子弹预制体包含NetworkObject和NetworkTransform。当玩家按下攻击键时客户端发送FireServerRpc服务器负责实例化子弹预制体并赋予其初始速度和方向。子弹的移动和碰撞检测在服务器进行确保公平性。生命值与伤害为玩家添加一个NetworkVariableint类型的Health。当子弹碰撞时服务器减少被击中玩家的Health并同步该值到所有客户端。当Health0时服务器销毁玩家对象NetworkObject.Despawn或触发重生逻辑。7.4 引入游戏状态管理目前游戏没有开始、结束的概念。你需要一个管理全局状态的脚本通常挂载在一个永存的网络对象上比如NetworkManager本身或一个专门的GameManager对象。这个管理器负责游戏阶段通过NetworkVariable同步游戏当前处于“等待中”、“进行中”、“结束”哪个阶段。玩家列表与分数使用NetworkList来维护所有已连接玩家的信息NetworkId, 玩家名, 得分等。开始与结束逻辑当满足条件如玩家数达到2人时服务器调用Rpc通知所有客户端游戏开始。当达成胜利条件如一方生命值归零时服务器宣布游戏结束并可能在一段时间后重置。搭建这个双人移动对战原型的过程本质上是在学习如何将单机游戏的“输入-处理-渲染”循环拆解成“客户端输入预测、服务器权威计算、状态同步调和”的分布式循环。每一个环节的疏漏都会在网络上被放大成可见的问题。我个人的体会是联机开发初期不要急于堆砌功能而是应该像我们这样先让最基础的移动同步稳如磐石。多进行构建测试用两个独立的客户端程序去观察和调试你会对客户端、服务器、Rpc调用、状态同步这些概念有肌肉记忆般的理解。当你能清晰地解释为什么这里的移动要用ServerRpc那里的颜色要用NetworkVariable时你就已经跨过了NetCode入门最难的一道坎。
返回列表