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 NetworkVariable<Color> _playerColor = new NetworkVariable<Color>(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 等方法保持不变 ... }这段代码做了几件事:
- 声明了一个
NetworkVariable<Color>类型的_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的使用是一致的。
- 未使用NetworkTransform:确保玩家预制体上挂载了
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)。
- 命名规范:NetCode要求Rpc方法名必须以
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,服务器负责实例化子弹预制体,并赋予其初始速度和方向。子弹的移动和碰撞检测在服务器进行,确保公平性。 - 生命值与伤害:为玩家添加一个
NetworkVariable<int>类型的Health。当子弹碰撞时,服务器减少被击中玩家的Health,并同步该值到所有客户端。当Health<=0时,服务器销毁玩家对象(NetworkObject.Despawn)或触发重生逻辑。
7.4 引入游戏状态管理
目前游戏没有开始、结束的概念。你需要一个管理全局状态的脚本,通常挂载在一个永存的网络对象上(比如NetworkManager本身或一个专门的GameManager对象)。这个管理器负责:
- 游戏阶段:通过
NetworkVariable同步游戏当前处于“等待中”、“进行中”、“结束”哪个阶段。 - 玩家列表与分数:使用
NetworkList来维护所有已连接玩家的信息(NetworkId, 玩家名, 得分等)。 - 开始与结束逻辑:当满足条件(如玩家数达到2人)时,服务器调用Rpc通知所有客户端游戏开始。当达成胜利条件(如一方生命值归零)时,服务器宣布游戏结束,并可能在一段时间后重置。
搭建这个双人移动对战原型的过程,本质上是在学习如何将单机游戏的“输入-处理-渲染”循环,拆解成“客户端输入预测、服务器权威计算、状态同步调和”的分布式循环。每一个环节的疏漏都会在网络上被放大成可见的问题。我个人的体会是,联机开发初期,不要急于堆砌功能,而是应该像我们这样,先让最基础的移动同步稳如磐石。多进行构建测试,用两个独立的客户端程序去观察和调试,你会对客户端、服务器、Rpc调用、状态同步这些概念有肌肉记忆般的理解。当你能清晰地解释为什么这里的移动要用ServerRpc,那里的颜色要用NetworkVariable时,你就已经跨过了NetCode入门最难的一道坎。