三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity多人游戏开发入门:基于Netcode for GameObjects实现网络同步与客户端预测

Unity多人游戏开发入门:基于Netcode for GameObjects实现网络同步与客户端预测

1. 项目概述与核心价值

如果你是一个Unity开发者,并且你的游戏项目清单里一直躺着“做个能和朋友一起玩的联机游戏”这一项,但每次都被“网络同步”、“客户端预测”、“服务器架构”这些词吓退,那么今天这篇内容就是为你准备的。我们聚焦于Unity官方推出的Netcode for GameObjects (NGO),这是目前Unity生态中构建多人游戏最主流、最易上手的框架。我将以一个典型的“第三人称射击”或“多人竞技场”游戏的基础搭建为例,带你从零开始,在一天内(对应标题的Day 39)打通从单机到多人联机的核心链路。这不仅仅是安装一个包、拖几个组件那么简单,我会深入拆解每个操作背后的网络逻辑,让你明白为什么这么做,以及在实际项目中可能会遇到哪些“坑”。

多人游戏开发的核心挑战在于,如何在网络延迟和丢包的不确定环境下,让所有玩家看到一个“尽可能一致”的游戏世界。NGO提供了一套高层次的抽象,帮我们处理了底层的网络通信、对象生成与销毁、状态同步等繁重工作。通过本篇,你将掌握使用NGO创建网络管理器、配置玩家预制体、实现基础的移动同步与简单交互,并理解“客户端权限”与“服务器权限”这两种核心同步模式的区别与应用场景。无论你是想做一个简单的同屏派对游戏,还是一个需要复杂状态同步的在线竞技游戏,这里的基础都将是你坚实的起点。

2. 核心概念与NGO架构解析

在动手写代码之前,我们必须先理解NGO的几个基石性概念。这能让你在后续遇到问题时,知道该从哪个方向去排查。

2.1 客户端、服务器与主机(Host)

在NGO的语境下,通常有三种角色:

  1. 纯客户端 (Client):只负责接收游戏状态、渲染画面、发送玩家输入。它不参与游戏逻辑的权威计算。
  2. 纯服务器 (Server):不进行渲染,只负责运行权威的游戏逻辑(如物理计算、伤害判定、胜负判断),接收所有客户端的输入,计算新的游戏状态,并广播给所有客户端。这是最经典的“客户端-服务器”架构。
  3. 主机 (Host):这是一个特殊的混合体。它既运行服务器逻辑(作为权威端),同时也作为一个客户端运行(进行渲染和本地玩家输入)。在开发测试和小型游戏中,Host模式非常方便,因为你只需要运行一个Unity实例就能模拟多人环境。

注意:在NGO中,NetworkManager组件默认启动的就是Host模式。这对于快速原型开发和测试至关重要。

2.2 NetworkObject与NetworkBehaviour

这是NGO中两个最重要的组件。

  • NetworkObject:任何需要在网络上同步的GameObject都必须挂载此组件。它赋予了这个GameObject一个全局唯一的网络ID,NGO通过这个ID来管理对象的生成、销毁和所有权。你可以把它理解为这个GameObject在网络世界的“身份证”。
  • NetworkBehaviour:继承自MonoBehaviour。你的网络相关脚本(如玩家控制、同步生命值)都需要继承自这个类,而不是MonoBehaviour。它提供了一系列网络生命周期钩子(如OnNetworkSpawn)和网络相关功能(如RPC)。

2.3 同步机制:NetworkVariable与RPC

NGO提供了两种主要的数据同步方式,用于不同的场景:

  • NetworkVariable:用于持续同步变化相对不频繁的状态数据。例如玩家的生命值、得分、队伍颜色。你只需要在NetworkBehaviour脚本中声明一个NetworkVariable<T>类型的公共变量,NGO就会自动在值发生变化时将其同步给相关客户端。它支持多种同步方式(如所有人、仅所有者、仅服务器)。
    public NetworkVariable<int> health = new NetworkVariable<int>(100);
  • 远程过程调用 (RPC):用于触发一次性的、即时的动作或事件。例如玩家开枪、释放技能、发送聊天消息。RPC分为两种:
    • ServerRpc:从客户端调用,在服务器上执行。用于客户端请求服务器执行一个权威操作。
    • ClientRpc:从服务器调用,在所有或特定客户端上执行。用于服务器通知所有客户端某个事件的发生。

理解何时使用NetworkVariable,何时使用RPC,是设计高效网络逻辑的关键。通常,状态用NetworkVariable,事件用RPC。

2.4 权限模型:谁说了算?

这是多人游戏公平性的核心。NGO主要涉及两种权限:

  • 服务器权威 (Server Authoritative):服务器拥有游戏状态的最终决定权。所有关键逻辑(如移动验证、伤害计算)都在服务器上运行。客户端只负责发送输入和显示插值后的结果。这是竞技性游戏的标配,能有效防止外挂。
  • 客户端权威 (Client Authoritative):客户端对自己的部分游戏对象(通常是玩家自身)拥有控制权,可以直接修改状态并同步给服务器和其他客户端。这种方式实现简单、响应快,但安全性差,容易作弊。NGO中,通过设置NetworkObjectOwnership,并结合NetworkTransformAuthority模式,可以实现客户端权威移动。

对于新手,我强烈建议从服务器权威模型开始。虽然初期会多花一些心思在输入处理和状态验证上,但这能为你的游戏打下坚实、可扩展的基础。

3. 环境准备与项目初始化

现在,让我们开始动手。假设你已经有一个基本的第三人称角色控制器(可以使用Unity的Starter Assets或自己实现)。

3.1 创建新项目与导入NGO

  1. 使用Unity Hub创建一个新的3D项目(建议使用Unity 2022 LTS或更新版本,对NGO支持更好)。
  2. 打开Package Manager(Window -> Package Manager)。
  3. 点击左上角“+”号,选择“Add package by name...”。
  4. 输入并安装以下两个核心包:
    • com.unity.netcode.gameobjects(这是NGO本体)
    • com.unity.multiplayer.tools(网络工具包,包含有用的调试工具)
  5. 等待导入完成。你可能会看到一些示例场景和工具窗口,可以先忽略。

3.2 设置基础场景与NetworkManager

  1. 在你的场景中,创建一个空的GameObject,命名为“NetworkManager”。
  2. 选中它,在Inspector中点击“Add Component”,搜索并添加NetworkManager组件。
  3. 你会看到NetworkManager自动添加了UnityTransport组件作为网络传输层。保持默认设置即可,它使用UDP协议,适合实时游戏。
  4. 关键一步:在Project窗口中,右键点击Assets文件夹,选择Create -> Netcode -> NetworkManager Prefab。这会在Assets下创建一个名为“NetworkManager.prefab”的预制体。将场景中刚创建的NetworkManager对象拖到这个预制体上进行保存。之后,请删除场景中的NetworkManager实例,从Prefab文件夹中拖一个预制体到场景里。这样做是为了确保NetworkManager的配置在场景加载时能被正确初始化,是一个最佳实践。

NetworkManager是你的网络游戏中枢,它管理客户端连接、场景切换和网络预制体的注册。

3.3 创建可网络化的玩家预制体

这是最关键的一步。你的玩家角色必须是一个网络对象。

  1. 找到或创建你的玩家角色预制体(例如“PlayerPrefab”)。
  2. 选中该预制体,在Inspector最下方点击“Add Component”,添加NetworkObject组件。
  3. (重要)设置玩家生成:在之前创建的NetworkManager预制体上,找到NetworkManager组件,其中有一个“Player Prefab”的槽位。将你的“PlayerPrefab”拖拽进去。这告诉NGO:当新客户端连接时,应该生成这个预制体作为该客户端的玩家角色。
  4. 现在,为你的玩家控制脚本(假设叫PlayerController)修改基类。将public class PlayerController : MonoBehaviour改为public class PlayerController : NetworkBehaviour
  5. PlayerController脚本中,所有只应由本地玩家(即自己控制的角色)执行的代码(如处理输入、控制相机),都需要包裹在if (IsOwner)if (IsLocalPlayer)的判断里。这是多人游戏编程的黄金法则。
    void Update() { if (!IsOwner) return; // 如果不是这个客户端的角色,不执行输入处理 // 以下代码只有本地玩家才会执行 float moveX = Input.GetAxis("Horizontal"); float moveZ = Input.GetAxis("Vertical"); // ... 处理移动逻辑 }

4. 实现基础移动同步

让角色在网络上动起来,是第一个要攻克的难关。我们有几种选择,这里介绍最常用的两种。

4.1 方案一:使用NetworkTransform(快速原型)

这是最简单粗暴的方式,适合快速验证想法。

  1. 在你的玩家预制体上,添加NetworkTransform组件。
  2. 保持默认设置。NetworkTransform会自动同步该GameObject的Transform(位置、旋转、缩放)到网络。
  3. 在你的PlayerController脚本的Update中,像写单机游戏一样编写移动逻辑(但务必放在if (IsOwner)判断内)。
  4. 运行游戏。先以Host模式启动一个实例(在NetworkManager组件点击“Host”按钮),再以Client模式启动另一个Unity实例(点击“Client”按钮并连接到localhost)。你应该能看到两个角色,并且可以分别控制它们移动,同时能看到对方的移动。

原理与坑点

  • NetworkTransform默认使用服务器权威模式。这意味着,客户端发送自己的位置给服务器,服务器接收所有客户端的位置,再广播给所有客户端。这能防止客户端发送虚假位置作弊。
  • 但它会带来输入延迟。你按下按键,角色移动,这个移动信息需要先传到服务器,再广播回来,你才能看到自己角色的最终移动。这会感觉“不跟手”。
  • 对于需要快速响应的玩家控制,这不是最佳方案。但对于NPC、环境物体等非玩家对象,NetworkTransform非常合适。

4.2 方案二:客户端预测 + 服务器协调(推荐方案)

为了获得流畅的本地操作体验,我们必须实现客户端预测。核心思想是:客户端不等待服务器确认,立即根据输入移动本地角色,同时将输入发送给服务器;服务器运行相同的逻辑进行权威计算,并将权威状态定期发回客户端;客户端收到后,如果发现自己的预测位置与服务器位置有偏差,则进行平滑纠正(回滚或插值)。

这是一个进阶话题,但NGO提供了NetworkTransform的客户端预测实验性支持,我们可以基于此进行简化实现。更完整的方案需要自己处理状态快照和回滚。

简化实现步骤:

  1. 依然使用NetworkTransform组件,但将其Interpolate设置为true,这会让非本地玩家的角色移动更平滑。
  2. PlayerController中,我们实现一个简单的预测逻辑:
    • Update中,本地玩家立即根据输入移动(transform.Translate)。
    • 同时,将本次输入的向量和时间戳通过一个ServerRpc发送给服务器。
    private void Update() { if (!IsOwner) return; Vector3 moveInput = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")); if (moveInput.magnitude > 0.1f) { // 客户端预测:立即移动 transform.Translate(moveInput * moveSpeed * Time.deltaTime); // 将输入发送给服务器进行权威验证 SendMoveInputServerRpc(moveInput); } } [ServerRpc] private void SendMoveInputServerRpc(Vector3 input) { // 服务器端权威移动计算 // 这里应该使用和客户端相同的逻辑,但基于服务器的时间 transform.Translate(input * moveSpeed * Time.deltaTime); // 服务器计算后,新的位置会通过NetworkTransform自动同步给所有客户端 }
  3. 这个简化版本存在“橡皮筋”效应(客户端预测的位置被服务器权威位置覆盖时发生的跳跃)的风险。在实际项目中,你需要引入命令队列状态快照插值/回滚机制。这通常需要借助NetworkVariable来同步一个由服务器计算的“目标位置”,客户端朝这个目标位置进行平滑插值,而不是直接硬塞。

实操心得:对于你的第一个多人游戏项目,我建议先使用方案一的NetworkTransform(服务器权威)把整个游戏流程跑通。在体验到延迟问题后,再着手研究方案二。不要一开始就追求完美的预测,那会让你陷入复杂的泥潭。先做出一个“可玩”的版本,迭代优化。

5. 实现简单的网络交互:同步射击

移动之后,我们来实现一个简单的射击功能,巩固对RPC的理解。

  1. 创建子弹预制体:创建一个球体或胶囊体作为子弹,为其添加NetworkObject组件和一个刚体Rigidbody。创建一个脚本Bullet,继承NetworkBehaviour,里面可以写碰撞检测和自毁逻辑。将其保存为预制体“BulletPrefab”。
  2. 在NetworkManager中注册子弹预制体:在NetworkManager组件的“Network Prefabs”列表(或通过NetworkPrefabs列表属性)中,添加“BulletPrefab”。这样NGO才知道如何在网络上生成这个对象。
  3. 修改PlayerController,添加射击逻辑
    public GameObject bulletPrefab; // 在Inspector中拖入BulletPrefab public Transform firePoint; private void Update() { if (!IsOwner) return; // ... 移动逻辑 ... if (Input.GetButtonDown("Fire1")) { // 请求服务器生成子弹 RequestFireServerRpc(); } } [ServerRpc] private void RequestFireServerRpc() { // 注意:只在服务器上生成网络对象! GameObject newBullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); NetworkObject bulletNetworkObject = newBullet.GetComponent<NetworkObject>(); bulletNetworkObject.Spawn(); // 关键:调用Spawn使其在网络中存在 // 可选:为子弹添加一个力 Rigidbody rb = newBullet.GetComponent<Rigidbody>(); if (rb != null) { rb.AddForce(firePoint.forward * bulletForce, ForceMode.Impulse); } }
  4. 子弹碰撞与销毁:在Bullet脚本中,检测碰撞,并在服务器上销毁对象。
    private void OnCollisionEnter(Collision collision) { if (!IsServer) return; // 只有服务器能执行销毁逻辑 // 处理伤害等逻辑... // ... // 销毁网络对象 NetworkObject.Despawn(true); // true表示同时销毁GameObject }

关键点解析

  • 生成对象:网络对象的生成(Spawn)必须在服务器端调用。客户端通过ServerRpc请求,服务器执行生成并自动同步给所有客户端。
  • 销毁对象:同理,网络对象的销毁(Despawn)也应由服务器发起。在上例中,子弹在服务器上检测到碰撞后调用Despawn,所有客户端上的该子弹对象也会被销毁。
  • 所有权:谁生成的对象,默认谁就是所有者。服务器生成的子弹,服务器就是所有者。你也可以在Spawn时指定所有者。

6. 调试、测试与常见问题排查

多人游戏调试比单机复杂得多。以下是一些必备工具和技巧。

6.1 使用ParrelSync进行本地多实例测试

手动打开多个Unity编辑器实例并构建运行非常麻烦。ParrelSync是一个开源工具,可以让你在同一个项目下,快速克隆并运行多个编辑器实例进行联机测试。

  1. 通过Package Manager的“Add package from Git URL”安装:https://github.com/VeriorPies/ParrelSync.git
  2. 安装后,在Unity编辑器菜单栏会多出一个“ParrelSync”选项。你可以通过它快速创建项目副本并打开新的编辑器实例,它们共享项目资产但拥有独立的临时数据,完美模拟多个客户端。

6.2 NGO内置网络调试器

安装com.unity.multiplayer.tools包后,你可以在Window -> Analysis -> Multiplayer Tool窗口中打开网络调试器。它可以实时显示网络流量、RPC调用、对象生成销毁等信息,是定位网络问题(如带宽过高、RPC丢失)的利器。

6.3 常见问题与解决方案速查表

问题现象可能原因排查步骤与解决方案
客户端连接失败1. 服务器未启动。
2. 防火墙/端口阻止。
3.UnityTransport配置错误。
1. 确认先启动Host/Server。
2. 检查本地防火墙设置,确保端口(默认7777)开放。本地测试可暂时关闭防火墙。
3. 检查NetworkManager上的UnityTransport组件,确认服务器地址(localhost)和端口一致。
玩家预制体生成位置错误1.NetworkManager中未设置Player Prefab。
2. Player Prefab上没有NetworkObject
3. 生成点问题。
1. 确认预制体已拖入NetworkManager的Player Prefab槽位。
2. 确认预制体根节点有NetworkObject组件。
3. 检查场景中是否有NetworkStartPosition组件,或服务器生成逻辑中的位置计算。
控制不了自己的角色玩家控制脚本没有进行IsOwner判断。在所有处理本地输入(Update,FixedUpdate)和相机控制的代码块开始处,加上if (!IsOwner) return;
能看到其他玩家,但移动卡顿或“瞬移”1. 网络延迟高。
2. 未使用插值。
3.NetworkTransform同步频率过低。
1. 本地测试排除网络问题。
2. 为其他玩家的NetworkTransform启用插值(Interpolate)。
3. 适当提高NetworkTransformNetworkTickRate(但会增加带宽)。
RPC调用没反应1. RPC方法命名未以ServerRpc/ClientRpc结尾。
2. 参数类型不被支持。
3. 在客户端调用了ClientRpcClientRpc只能从服务器调用)。
1. 确保RPC方法名后缀正确。
2. NGO RPC支持基础类型、NetworkObject引用等,检查参数。
3. 理清调用链:客户端动作应触发ServerRpc,服务器处理后再用ClientRpc通知所有客户端。
构建后无法连接1. 发布构建的服务器地址配置错误。
2. 服务器程序未正确部署或运行。
1. 构建时,在代码或UI中动态设置连接地址,不要硬编码localhost
2. 确保服务器构建(如独立构建的Headless Server)已正确启动并监听端口。

6.4 性能优化初探

当你的游戏对象多起来后,需要注意网络性能。

  • 减少同步频率:不是所有数据都需要每帧同步。对于变化缓慢的NetworkVariable(如玩家名称),可以降低同步频率。
  • 优化RPC使用:避免在Update中每帧调用RPC。例如,移动输入可以累积一段时间或按固定频率发送。
  • 使用兴趣管理(AOI):对于大型世界,NGO提供了基于距离的兴趣管理,只同步玩家附近的物体。这需要更复杂的配置,但对大型多人游戏至关重要。
  • 监控带宽:始终使用网络调试工具监控数据流量。一个简单的规则:目标带宽应适应你的目标用户网络(如移动网络需更严格)。

走到这里,你已经成功搭建了一个最基础的、可运行的Unity多人游戏原型。你理解了客户端与服务器的区别,掌握了使用NetworkObjectNetworkBehaviour创建网络实体,实践了通过NetworkTransform和RPC进行状态同步与事件通信,并学会了基础的调试方法。这仅仅是Day 39的成果,也是你深入多人游戏开发世界的门票。接下来的路,你会遇到更复杂的挑战:状态同步的精准性、延迟补偿的完备性、游戏逻辑的服务器迁移、房间与匹配系统、以及最终的云端部署。但请记住,所有复杂的系统都是由今天这些基础模块构建而成的。建议你以这个原型为基础,尝试添加一个简单的生命值系统(用NetworkVariable同步)、一个得分板,或者一个简单的获胜条件,在实践中继续巩固这些概念。当你亲手实现的功能在多个客户端间流畅运行时,那种成就感是单机开发无法比拟的。

← 返回列表