【unity游戏开发——网络】一个免费、强大、性能优越且高度模块化的 Unity 网络库 —— PurrNet的使用介绍
PurrNet是一款完全免费的Unity网络解决方案,提供简单高效的多人游戏开发体验。其核心特性包括:支持服务器验证和所有人模式两种网络规则,实现灵活的权限控制;通过原生Unity操作自动处理对象生成/销毁同步;提供多种RPC类型和所有权管理功能;内置网络Cookie实现玩家数据持久化。安装时需额外配置Newtonsoft插件,通过NetworkManager组件快速搭建网络环境。文档还演示了输入
文章目录
- 一、前言
- 二、入门指南
- 三、简易多人物理(输入同步)
- 四、系统和模块
-
- 1、网络管理器 (Network Manager)
- 2、传输方式(Transports)
- 3、网络身份组件(Network Identity)
- 4、网络模块 (Network Modules)
- 5、远程过程调用 (Remote Procedure Calls)
- 6、广播 (Broadcasts)
- 7、场景管理 (Scene Management)
- 8、实例处理器 (Instance Handler)
- 9、碰撞器回滚(延迟补偿)
- 10、BitPacker(序列化系统)
- 11、代码剥离 (Code Stripping)
- 12、带宽分析器 (Bandwidth Profiler)
- 五、一键式组件
- 专栏推荐
- 完结
一、前言
PurrNet 打造的是 “完美” 网络解决方案的尝试:它是一款 100% 免费的 Unity 网络解决方案,没有专业版或高级版本,也没有功能会被付费墙限制。你可以用它发布作品,不会索要任何回报!
它的设计参考了多款网络系统,比如 Mirror、Fish-Net 以及 Photon Quantum。保留了这些系统中优秀的设计,并在有需要的地方进行改进或创新。
高层级 API 可以让网络开发变得简单又快速,同时还能给开发者充足的自由度。你可以使用 网络规则系统,定义自己偏好的工作流与游戏运行逻辑,从完全的服务器验证(安全模式),到完全的客户端验证(所有人模式,不安全),都可以设置,以此实现快速的多人游戏开发!
文档:https://purrnet.gitbook.io/docs/getting-started/installation-setup
所有人模式 vs 服务器验证?
在 PurrNet 和这份文档中,你会频繁看到两个概念:服务器验证(Server Auth) 和 所有人模式(Everyone)。
- 服务器验证:只有服务器可以执行指定的操作。如果你在网络规则中设置为 “Server”,那么只有服务器能执行对应操作。
- 所有人模式:所有客户端和服务器都可以执行指定的操作。这种模式在技术上会创建一个 “不安全” 的环境,很容易出现作弊行为,因此仅推荐在作弊影响不大的游戏中使用,比如合作游戏或者友好的玩家对战游戏。
易用性
PurrNet 最核心的特点就是易用性和项目可扩展性,这很大程度上依赖于 网络规则系统,以下是相关示例:
- 生成与销毁对象
在大部分网络解决方案中,你通常需要先实例化一个对象,再执行一个独立的生成调用,同时还要确保实例化和生成调用都在服务器端执行。
但在 PurrNet 中,你只需要按照 Unity 的原生习惯,直接实例化对象即可,剩下的都会自动处理。
通过网络规则,你可以设置为 “所有人模式”,让所有客户端都能通过直接实例化来生成对象;如果你只想要服务器验证,只需要在服务器端执行实例化,其余的流程也会自动处理。
销毁对象也是一样的逻辑:直接调用 Unity 的Destroy()方法,剩下的同步逻辑会自动完成网络同步。 - RPC(远程过程调用)与 SyncVar(同步变量)
通过网络规则,你可以设置 SyncVar 或者 RPC 是否允许 “所有人” 调用。这意味着你不需要像其他网络解决方案那样,确保逻辑在服务器端运行,能让代码编写更快,学习和使用的流程也更简单。
还实现了(就我们所知)此前没有的 RPC 类型:- 通用 RPC
- 静态 RPC
- 可等待 RPC
- 对象所有权
当你生成或销毁对象时,可以针对每个对象单独修改它的默认所有者。如果生成和销毁规则设置为 “所有人模式”,你只需要修改设置,就能让生成对象的玩家成为该对象的所有者,不需要额外编写代码。
通过网络规则,你还可以轻松允许客户端修改对象的所有权,这比目前其他的网络解决方案都要更易用。
持久化用户数据与连接
PurrNet 还有一个实用的内置功能:网络 Cookie。它可以让服务器在玩家断开连接后,仍然保留对该玩家的识别信息。如果玩家断开连接后再次连接,服务器可以识别出这是同一个玩家,并且可以保留之前存储的数据。你可以自定义数据的存储位置和持久化的范围(连接、进程、设备)。
插件库
Asset Library(资源库)是一个自定义的 Unity 窗口,你可以通过 Window -> PurrNet Addon Library 打开。你可以在这个窗口中获取 PurrNet 的各类可选内容,比如新的传输方式、示例、工具等。
二、入门指南
1、安装
https://github.com/PurrNet/PurrNet/releases/tag/v1.18.0
2、安装Newtonsoft插件
导入unity你可能会发现一些错误。那是因为缺少Newtonsoft插件
方法一:从NuGet下载HtmlAgilityPack包
从官方网站下载:https://www.newtonsoft.com/json
方法二、使用NuGetForUnity安装
使用NuGetForUnity安装:https://github.com/GlitchEnzo/NuGetForUnity
安装缺失的插件
2、设置
-
新增NetworkManager空物体,添加
Network Manager组件(会自动追加UDP Transport组件)
-
填写"Network Rules"字段(推荐:Unsafe)。

-
填写"Network Prefabs"字段(推荐:直接点击"
New"按钮)
-
启动你的 Unity 编辑器,观察网络管理器在服务器和客户端都亮起。这意味着你现在正在作为主机运行。

三、简易多人物理(输入同步)
在我看来,最简单的实现多人物理交互的方式就是使用输入同步。最流行的替代方案是客户端预测(CSP)。两者各有优缺点。
1、输入同步 vs 客户端预测
客户端预测
✔️ 玩家即时响应
✔️ 易于防作弊
❌ 逻辑复杂,难以处理
输入同步
✔️ 工作流程简单(几乎和单机代码一样简单)
✔️ 易于防作弊
❌ 对延迟敏感
请记住,虽然它不会像本地运行那样真正即时,但你仍然可以通过优化游戏效果(例如在本地处理动画)来“制造假象”,使其在本地感觉是即时的。
2、为什么不能直接在每个客户端上运行物理?
如果你的游戏中的物理效果不会在玩家之间产生交互,那么完全可以这样做。但是,如果你希望玩家之间存在交互,就必须有一个单一的权威来源。
这就是为什么需要采用各种技术来正确地联网处理物理交互。它们都各有优缺点,最终你需要选择适合你游戏的方法!
3、它是如何工作的
其理念和执行都非常简单。本质上,它是一个完全由服务器权威模拟的系统,并通过网络变换传达给客户端。
简单来说:客户端只向服务器发送其输入和意图,服务器在本地实际执行操作,从而使服务器成为整个游戏的单一权威来源。这使其能够防作弊,并且能够处理物理交互(因为所有交互都在一个地方模拟)。
4、简单示例
这基本上是视频中展示的示例代码,并添加了注释来解释发生了什么。
[SerializeField] private float moveForce = 10f;
[SerializeField] private float jumpForce = 10f;
[SerializeField] private float bounceForce = 10f;
[SerializeField] private Rigidbody rigidbody;
private bool _willJump;
protected override void OnSpawned(bool asServer)
{
base.OnSpawned(asServer);
if (asServer)
return;
// 所有客户端都设置为运动学,这样只有服务器运行物理!
rigidbody.isKinematic = !isServer;
// 只有所有者启用此脚本以运行 Update()
enabled = isOwner;
// 只有所有者运行 OnTick 以向服务器发送输入
if (isOwner)
networkManager.onTick += OnTick;
}
protected override void OnDestroy()
{
base.OnDestroy();
// 清理时取消订阅
networkManager.onTick -= OnTick;
}
private void Update()
{
// 我们需要存储输入以便在下一次 Tick 中使用
if (Input.GetKeyDown(KeyCode.Space))
_willJump = true;
}
private void OnTick(bool asServer)
{
// 在主机设置的情况下,我们不希望这运行两次。
if (asServer)
return;
// 我们生成将要发送到服务器的输入结构体
var input = new InputData()
{
movement = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")),
jump = _willJump
};
// 在 Tick 中使用后,将跳跃布尔值重置
_willJump = false;
// 我们将输入发送到服务器
Move(input);
}
// 使用 Unreliable 通道的服务器 RPC 以更高效地发送数据
[ServerRpc(Channel.Unreliable)]
private void Move(InputData inputData)
{
// 这里你也可以处理输入数据的作弊检测
// 例如,如果幅度大于 1,可以对其进行归一化
// 从服务器的角度来看,这里的代码基本上是“单机”代码
// 我们根据给定的输入生成移动向量。
var movement = new Vector3(inputData.movement.x, 0, inputData.movement.y) * moveForce;
rigidbody.AddForce(movement);
if(inputData.jump)
rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
private void OnCollisionEnter(Collision other)
{
// 除了这里的 if 语句外,从服务器的角度来看,这是单机代码
if (!isServer)
return;
if (!other.gameObject.TryGetComponent(out PlayerPhysicsMovement otherPlayer))
return;
var direction = (transform.position - other.transform.position).normalized;
rigidbody.AddForce(direction * bounceForce, ForceMode.Impulse);
}
// 用于保存输入数据的结构体。这不是必需的,只是一种清晰的方法
private struct InputData
{
public Vector2 movement;
public bool jump;
}
四、系统和模块
1、网络管理器 (Network Manager)
网络管理器是整个网络系统的核心。它充当 PurrNet 的心脏和大脑,网络的所有设置都通过网络管理器进行配置。
Start Server Flags启动服务器标志:这些是服务器在检测到 NetworkManager 后自动启动的条件。
Start Client Flags启动客户端标志:这些是客户端在检测到 NetworkManager 后自动启动的条件。
Cookie Scope Cookie 作用域:PurrNet 为数据提供 Cookie/缓存功能,此设置决定 Cookie 在 PurrNet 中的作用方式和范围。
- Live With Connection(随连接存在):连接停止后,所有内容都不会被存储。
- Live With Process(随进程存在):游戏/进程停止后,所有内容都不会被存储。
- Store Persistently(持久化存储):Cookie 存储在您系统的 PlayerPrefs 中。
Dont Destroy On Load:跨场景时不销毁
Transport(传输协议):这是用于发送数据的传输方式。默认情况下,您很可能希望使用 UDP 传输协议。您只需将传输组件添加到同一个游戏对象,并将其拖拽到此字段中。
Network Prefabs(网络预制件):这些是要使用的 网络预制件。只需简单地选择相应的 ScriptableObject。
Network Rules(网络规则):这些是 Network Manager 要使用的 网络规则。只需选择您想要使用的 ScriptableObject。此设置可以在单个 网络标识 上进行覆盖。
Visibility Rules(可见性规则):这是要使用的 可见性 规则集。这将决定玩家与网络标识之间关系的基础规则。此设置可以在单个 网络标识 上进行覆盖。
1.1 网络规则 (Network Rules)
网络规则是 PurrNet 中最独特且实用的功能之一。它们允许您完全自定义网络体验,以实现最简单的工作流程或实现完全服务器认证,以确保无作弊体验。
您可以创建自己的网络规则集,但 PurrNet 也预先打包了一些。在大多数情况下,这些预置规则就足够了。
- 如果您希望获得最佳/最简单的开发流程,应选择“Unsafe”规则集。它为开发者提供了最大的自由度。
- 如果您希望像大多数具有完整服务器权限的网络系统一样运行,应选择“ServerStrict”。
- “ServerOwner”规则集也提供了极大的灵活性,同时不会带来显著的作弊风险。
如果您查看 ScriptableObject,这些规则大多是自解释的。它允许您调整以下内容(但不限于):
- 生成和销毁对象的权限
- 默认所有者
- 授予所有权时所有权如何运作
- 谁可以分配所有权
- 谁负责同步值
- 以及更多。
1.2 网络预制件 (Network Prefabs)
网络预制件是一个 ScriptableObject,PurrNet 用它来交叉标识预制件。它用于对象的 生成和销毁。
它作为引用设置在你的 NetworkManager 上。
网络预制件 ScriptableObject 已拉取了一堆项目预制件
网络预制件 ScriptableObject 的设置允许您轻松修改其拉取对象的来源。如果您有一个预制件文件夹用于存放所有预制件,最佳做法是将其文件夹设置为该路径。它将自动搜索任何子文件夹。
设置还可以修改是否应自动生成以查找预制件,或者您希望手动处理。最后,它还可以查找任何非网络对象并将其添加进来,因此即使是没有网络标识脚本的对象也可以通过网络生成。这将自动为其添加 PrefabLink 脚本。
1.3 网络资产 (Network Assets)
网络资产是一个 ScriptableObject,PurrNet 用它来交叉标识项目中的任何对象。它在读取和写入过程中用于交叉引用,意味着此 ScriptableObject 中的任何内容都可以用于同步、RPC 等!
它作为引用设置在你的 NetworkManager 上。
网络资产 ScriptableObject 的设置允许您轻松修改在点击生成按钮时将拉取哪些项目对象/资产(例如,材质,精灵,
Scriptables)通过索引来引用它们,以实现高效的联网。
如果您在游戏中添加或删除类型(例如制作新的 ScriptableObject),则可能需要刷新类型列表。
如何使用它?
您只需要在网络管理器中分配网络资产,然后照常进行网络操作,它就会正常工作!
(请确保您的资产在资产列表中)
这是必需的吗?
绝对不是。这是一个“选择加入”的情况。您可以用它来联网任何内容,但您不必这样做,您仍然可以轻松编写自己的序列化。这用作第一层回退,因此如果某些类型无法写入,它将从网络资产 ScriptableObject 发送它。
1.4 网络可见性 (Network Visibility)
总体而言,该系统决定服务器是否应将来自网络标识的数据发送到特定客户端。Network Manager 网络管理器 接受一个网络可见性规则集,该规则集由规则/条件组成。
在 Network Rules 网络规则 中,您可以修改此行为,决定对象是被销毁、取消生成,还是简单地保持存在但不接收或发送数据。
一个简单的例子是 Distance Condition距离条件。如果距离设置为 50 个单位,如果玩家与对象的距离超过 50 个单位,他们将不会接收到任何数据。这对于性能非常有益,尤其是在大型环境和游戏中,这样我们就不必发送不必要的数据。
网络可见性规则/条件可以作为全局条件添加,但也可以通过添加自定义规则集作为覆盖,在单个 Network Identity网络标识 基础上添加。
您还可以在预制条件不合适的情况下自定义自己的条件。您所需要做的就是继承自 INetworkVisibilityRule 并实现 HasVisibility 规则,并根据是否应该发送数据简单地返回 true 或 false。
使用可见性
您必须手动触发可见性检查,这是有充分理由的!
使用自定义条件可能在处理方面消耗资源,并且可能很容易发生大量不必要的评估。可见性的使用通常取决于具体用例,例如:
如果您使用 Distance Condition距离条件,您可能只希望当标识移动了特定数量的单位时才重新评估可见性。因此,如果我们每秒评估一次,并且您有 1,000 个标识都静止不动,那么每秒就是 1,000 次本可轻松避免的评估。
幸运的是,实际评估标识的可见性非常简单!您只需在任何给定的标识上调用 EvaluateVisibility 方法。这样,您就可以轻松地为检查可见性的时机定制自己的逻辑。
距离条件 (Distance Condition)
距离可见性条件允许您设置一个单位数量,如果玩家距离某个标识超过该单位数量,他们将不会从该标识接收数据。
using UnityEngine;
namespace YourNamespace
{
[CreateAssetMenu(menuName = "PurrNet/NetworkVisibility/DistanceRule")]
public class DistanceRule : NetworkVisibilityRule
{
[SerializeField] private LayerMask _layerMask = ~0;
[SerializeField, Min(0)] private float _distance = 30f;
[SerializeField, Min(0)] private float _deadZone = 5f;
public override int complexity => 100;
public override bool CanSee(PlayerID player, NetworkIdentity networkIdentity)
{
var myPos = networkIdentity.transform.position;
bool wasPreviouslyVisible = networkIdentity.IsObserver(player);
foreach (var playerIdentity in manager.EnumerateAllPlayerOwnedIds(player, true))
{
var layer = playerIdentity.layer;
if ((_layerMask & (1 << layer)) == 0)
continue;
if (!playerIdentity.isActiveAndEnabled)
continue;
var playerPos = playerIdentity.transform.position;
var distance = Vector3.Distance(myPos, playerPos);
if (wasPreviouslyVisible)
{
if (!(distance <= _distance + _deadZone))
continue;
}
else if (!(distance <= _distance))
continue;
return true;
}
return false;
}
}
}
1.5 认证 (Authentication)
在 PurrNet 中,自定义认证器使您能够为多人游戏定义自己的认证逻辑。本指南将通过一个简单的基于密码的示例,引导您完成创建自定义认证器的步骤。
任何新连接在通过认证之前都不会被提升为
PlayerID。它们不会生成任何对象,不会接收任何 RPC 或玩家范围的广播,它们只会接收Connection范围的广播。
1. 为您的认证器创建一个脚本
在您的 Unity 项目中创建一个新的 C# 脚本,并为其命名一个有意义的名字,例如 CustomAuthenticator。
2. 实现自定义认证器类
您的自定义认证器类应继承自 AuthenticationBehaviour<T>。这确保您的类遵循 PurrNet 认证结构。
using System.Threading.Tasks;
using UnityEngine;
using PurrNet.Authentication;
namespace YourNamespace
{
// 此属性确保注册正确的类型变体
[RegisterNetworkType(typeof(AuthenticationRequest<string>))]
public class CustomAuthenticator : AuthenticationBehaviour<string>
{
[Tooltip("客户端认证所需的密码。")]
[SerializeField]
private string _password = "YourSecretPassword";
protected override Task<AuthenticationRequest<string>> GetClientPayload()
{
// 客户端将向服务器发送其密码
return Task.FromResult(new AuthenticationRequest<string>(_password));
}
protected override Task<AuthenticationResponse> ValidateClientPayload(string payload)
{
// 服务器将验证它并返回适当的响应
bool isValid = _password == payload;
return Task.FromResult(new AuthenticationResponse(isValid));
}
}
}
3. 使用自定义认证器
要使用您的自定义认证器,请将其作为组件添加到场景中的某个位置,并将其链接到您的网络管理器,如下所示:
恭喜!您已成功在 PurrNet 中创建了一个自定义认证器。这使您能够更安全、更灵活地控制对多人游戏的访问权限。尝试不同类型的认证器,并根据您的特定需求进行调整。
2、传输方式(Transports)
传输方式用于通过网络传输实际数据。
您选择的传输方式必须在网络管理器(Network Manager)中引用。PurrNet 的默认传输方式是 UDP 传输。
2.1 复合传输(Composite Transport)
复合传输可以接收多个传输方式,并会自动选择支持的传输方式。最简单的例子是:如果您同时使用了 Web 传输 和 UDP 传输,并将游戏上传到网页平台,它会自动使用 Web 传输。
其工作原理是自动选择列表中第一个被支持的传输层。这使您可以轻松地在项目中使用多玩家传输层,而无需手动管理。
这也提供了简单的跨平台支持,因为服务器会启动所有可能的传输层,客户端将选择其中之一进行连接。因此,您可以让 Web、Steam、Epic 等不同平台的玩家一起游戏!
2.2 UDP 传输(UDP Transport)
UDP 传输是多玩家游戏最常用的传输层。它允许您轻松地通过指定的端口连接到任何公共 IP 地址。
我们使用了 MIT 协议的开源项目 LiteNetLib 来支持可靠和不可靠的消息传输。
参数说明
- Server Port(服务器端口) - 服务器启动以及客户端连接所使用的端口。
- Max Connections(最大连接数) - 允许的最大客户端连接数量。
- Address(地址) - 服务器的 IP 地址。默认为本地主机(localhost)。
2.3 Web 传输(Web Transport)
Web 传输处理通过网页的连接,其处理方式与 UDP 传输不同,两者不能混用。
参数说明
- Server Port(服务器端口) - 服务器启动以及客户端连接所使用的端口。
- Max Connections(最大连接数) - 允许的最大客户端连接数量。
- Address(地址) - 服务器的 IP 地址。默认为本地主机(localhost)。
- Query (查询参数) - 用于指定自定义的 URL 查询字符串。
- Path (路径) - 用于指定自定义的 URL 路径。
- Enable SSL(启用 SSL) - 是否使用 SSL 加密。
- Cert Path(证书路径) - SSL 证书的路径。
- Cert Password(证书密码) - SSL 证书的密码。
- Ssl Protocols(SSL 协议) - SSL 所使用的协议。
2.4 本地传输(Local Transport)
本地传输模拟网络的行为,但实际上不发送任何数据。如果您希望在单人游戏模式下依然能使用网络代码和组件,但又不需要启动服务器或客户端,这将非常有用。
玩家仍将扮演 主机 的角色。
2.5 Steam 传输(Steam Transport)
Steam 传输默认随 PurrNet 提供,但至少需要安装 Steamworks.Net 包才能工作。

参数说明
- Server Port(服务器端口) - 用于连接的端口。
- Dedicated server (专用服务器) - 是否作为/连接到 Steam 游戏服务器。
- Peer To Peer(点对点连接) - 是否希望客户端使用 P2P 方式直接连接。
- Address(地址) - 要连接到的地址。对于 P2P 连接,这是 CSteamID。
重要提示:
Steam 不等于网络!开发者必须理解,网络功能不等于 Steam,Steam 也不等于网络功能。Steam 的“大厅”功能也与网络无关。它纯粹是 Steam
用户通过 Steam 共享元数据(用户名、Steam ID、聊天等)的一种方式。
Steam大厅只是找人和组队工具,真正的联机游戏数据需要另外的网络传输系统(比如PurrNet的UDP/Web传输)来处理。
2.6 Purr 传输(Purr Transport)
Purr 传输是我们为您提供的中继服务。我们希望提供最佳的开发体验,而无需您搭建服务器或进行端口转发即可在线测试游戏,这是我们试图为您解决的一个主要痛点。
该传输目前处于非常早期的阶段,将经历多次更改,您可能偶尔会遇到服务器重启。尽管如此,它应该足以满足测试目的。
它通过房间系统工作,您只需要指定一个唯一的房间名称,任何人都可以通过中继服务器连接到它。它仍然区分服务器和客户端,只有一个客户端可以作为主机。当主机断开连接时,其他所有人也会被踢出,这与其他传输方式类似。
我们为您提供了多个可用区域。目前,当您创建一个房间时,它会进行 ping 测试并选择离您最近的区域。我们在美国有 2 台服务器,在欧洲、亚太地区、巴西和南非各有一台服务器。这应该提供了足够的覆盖范围,如果需要,我们未来可能会扩展这些区域。
要使用此传输,只需添加 PurrTransport 组件并将其分配给您的 NetworkManager。区域仅在选择创建服务器/房间时重要。当加入现有会话时,不会考虑区域。 这意味着房间是全局的,其名称在所有区域中共享。
传输组件中默认的中继地址仅用于开发,禁止用于任何生产用途。如果您为自己的中继服务器进行设置,可以将此传输层用于生产环境。
2.7 EOS 传输(EOS Transport)
这是一个由社区制作的传输层,允许您将 Epic Online Services 用作 PurrNet 的传输层:https://github.com/quentinleon/PurrNetEOSTransport
2.8 模拟网络延迟(Simulating latency)
为了在本地测试时仍能获得真实的场景,我们需要能够模拟网络延迟。有些工具内置了延迟模拟器,但我们发现这些模拟器并不能很好地反映现实情况(因为某些内容的模拟方式可能不同)。
在本地测试时,获得最接近真实世界延迟体验的最佳/唯一方法是对整个进程(Unity 编辑器或构建的游戏)甚至整个系统的网络进行限速。
我们确实打算在未来实现一个可以为特定进程限速的系统,但目前我们还没有这样的功能。
有许多工具可以做到这一点,我们个人使用 Clumsy。
3、网络身份组件(Network Identity)
Network Identity 是大多数网络逻辑的核心。它充分利用了 Unity 基于组件的面向对象编程特性。Network Behaviour 同样继承自 Network Identity,这意味着几乎所有与网络交互的脚本,同时也是一个 Network Identity。
你可以直接继承 Network Identity,但通常更推荐让你的脚本继承 Network Behaviour。
Network Identity 组件可以拥有一个所有者。所有者的行为和权限取决于 Network Manager 中设置的 Network Rules。
单个游戏对象(GameObject)可以附加任意数量的 Network Identity 组件。所有权 不一定作用于整个游戏对象层面,也可以在组件级别上进行控制。因此,一个游戏对象可以包含多个由不同玩家拥有的组件。
Network Identity 上有一个设置,可以将所有权传播给它所在的整个对象。这在 Network Rules 中也是一个默认设置。
Network Identity 内部执行顺序
3.1 所有权 (Ownership)
所有权定义了一个 玩家 和一个 Network Identity 之间的关系。
当一个新的 Network Identity 生成时,就已经存在某种所有权状态。可能是无所有者(此时服务器拥有完全控制权),或者某个 玩家/客户端 成为其所有者。
拥有一个 Network Identity 的所有权,会根据 Network Manager 的 Network Rules 赋予你不同级别的操作权限。
请注意,拥有一个网络身份组件的所有权,并不自动意味着你拥有该对象上所有组件的所有权。这是一个可以在每个 Network Identity 组件上单独配置的设置。
轻松更改所有权:
你可以调用 GiveOwnership 方法,并传入新的所有者 PlayerID。
networkIdentity.GiveOwnership(newOwnerPlayerId);
检查所有权:
// 检查对象是否有所有者
if(owner.HasValue)
{
// 此对象有所有者
Debug.Log($"该对象的所有者是: {owner.Value}");
}
else
{
// 此对象无所有者
}
// 轻松检查自己是否是所有者
if(isOwner)
{
// 如果进入这里,说明我们是此对象的所有者
}
检查是否拥有对象“控制权”的简便方法:
如果满足以下任一条件,则你拥有控制权:
- 存在所有者,并且你就是所有者。
- 没有所有者,并且你是服务器。
可以使用 IsController 布尔值进行判断。
if(IsController)
{
// 我们要么是所有者,要么是在无主状态下作为服务器
}
else
{
// 我们对此对象没有控制权
}
3.2 生成与销毁 (Spawning & Despawning)
在 PurrNet 中生成和销毁网络对象,就像在 Unity 中实例化和销毁一样简单!
只要对象包含 Network Identity 组件(例如你的自定义脚本、预制体引用、网络变换组件等),它就会自动为其他客户端生成。
你甚至可以直接将预制体从 Unity 编辑器中的项目窗口拖入场景,它也能正常工作!
请注意,生成和销毁对象时,相关的 Network Rules 必须允许你相对于该对象的角色执行此操作。在“不安全”规则下,所有人都可以执行。
public NetworkIdentity myObject; // 预制体引用
private NetworkIdentity _spawnedObject;
private void SpawnMyObject() {
// 这将自动生成对象到网络上
_spawnedObject = Instantiate(myObject);
// 生成后可以赋予其所有权
_spawnedObject.GiveOwnership(localPlayer);
}
private void DespawnMyObject() {
// 和 Unity 中操作类似,直接销毁游戏对象,它也会从网络上销毁
if(_spawnedObject)
Destroy(_spawnedObject.gameObject);
}
3.3 客户端生成验证 (Client Spawning Validation)
客户端侧生成能立即给玩家反馈,体验很好。但如果你想在更可控的环境下防止滥用,可以注册验证器。这些验证器只在服务器端运行,允许你完全拒绝生成请求。如果生成被拒绝,发起请求的客户端会在本地销毁该对象。
需要注意的一点是,PurrNet 中的 SpawnPacket 可能是部分生成的,因为每个 Network Identity 都是独立的。为了确保验证的是简单的预制体生成,我们提供了 TryGetRawPrefab 方法,它既能验证这是一个完整的生成,也能返回使用的预制体。
以下是一个验证所有普通(非部分)预制体生成的简单示例:
using PurrNet;
using PurrNet.Logging;
using PurrNet.Modules;
using UnityEngine;
public class TestValidator : MonoBehaviour
{
[SerializeField] private NetworkManager _networkManager;
private void Awake()
{
// 订阅客户端生成验证事件
_networkManager.onClientSpawnValidate += ValidateSpawn;
}
private void OnDestroy()
{
if (_networkManager != null)
_networkManager.onClientSpawnValidate -= ValidateSpawn;
}
private bool ValidateSpawn(PlayerID player, SpawnPacket data)
{
// TryGetRawPrefab 会输出触发此生成请求的预制体
// 它同时验证了这是一个完整的生成(PurrNet 支持部分生成)
if (data.TryGetRawPrefab(_networkManager, out var prefab))
{
PurrLogger.Log($"玩家 {player} 的生成请求已通过验证: {data.prototype}\n预制体: {prefab.name}");
return true; // 允许生成
}
return false; // 拒绝生成
}
}
3.4 NetworkBehaviour
NetworkBehaviour 可以看作是用于处理网络行为的“MonoBehaviour”。它同样继承自 Network Identity。
在你的 NetworkBehaviour 脚本中,你可以调用 RPC,处理 数据同步,转移 所有权,等等。
3.5 PlayerIdentity
PlayerIdentity 让你能够非常便捷地基于所有权来访问玩家对象。PlayerIdentity 继承自 NetworkIdentity,因此你仍然拥有所有网络功能,它只是在顶部添加了一个便利层!
你可以轻松使用以下功能:
bool TryGetLocal(out T player)-> 获取本地玩家对象。bool TryGetPlayer(PlayerID playerId, out T player)-> 根据给定的 playerId 获取对应玩家对象。allPlayers-> 返回一个包含所有已注册玩家的只读字典。
简单示例:
// 1. 定义玩家类,继承 PlayerIdentity<T>
public class PlayerMovement : PlayerIdentity<PlayerMovement>
{
public void Teleport(Vector3 destination) {
// 在此处实现传送逻辑
}
}
// 2. 在其他地方使用
public class GameManager : NetworkIdentity
{
[SerializeField] private Transform teleportDestination;
public void TeleportPlayer(PlayerID targetPlayer)
{
// 尝试查找目标玩家
if(!PlayerMovement.TryGetPlayer(targetPlayer, out var player))
return; // 没找到
player.Teleport(teleportDestination.position);
}
public void TeleportLocalPlayer()
{
// 尝试查找本地玩家
if(!PlayerMovement.TryGetLocal(out var player))
return;
player.Teleport(teleportDestination.position);
}
public void TeleportAllPlayers()
{
// allPlayers 返回所有玩家的字典
foreach(var player in PlayerMovement.allPlayers.Values) {
player.Teleport(teleportDestination.position);
}
}
}
3.6 跨场景不销毁 (Don’t Destroy On Load)
你可以像通常那样将 Network Identity 移至 DontDestroyOnLoad (DDOL),但必须在网络启动之前进行。因此,最好在 Awake 中调用。
private void Awake()
{
DontDestroyOnLoad(this.gameObject);
}
3.7 对象池 (Pooling)
对象池是指重复利用不再使用的旧预制体。在 PurrNet 中,你可以对网络对象使用对象池。其工作原理是:当一个对象被销毁或你对其失去可见性时,我们不会真正 Destroy 它,而是将其存储在后台,直到你尝试再次生成同类对象。
启用对象池:
你可以在网络预制体上启用池化功能:
使用方法:
在 PurrNet 中使用对象池非常简单。事实上,只要勾选了复选框,你就已经完成了设置。你仍然像往常一样调用 Instantiate 和 Destroy,剩下的由 PurrNet 自动处理。
对象回收时的清理:
如果你需要在对象被回收到池中时重置一些数据,可以在你的 NetworkIdentity 中重写清理方法:
protected override void OnPoolReset()
{
// 在此处重置一些状态或数据
}
注意事项与引用修复:
PurrNet 允许单独池化子对象,甚至支持部分池化(一个预制体可能部分来自池,部分是新实例化的)。但这可能导致引用错乱或丢失。请注意,只有当你混合使用预制体部件和父级时,这才是一个问题。
为了解决这个问题,我们提供了一个 Reference<T> 模块供你使用。用法如下:
[SerializeField] private Reference<MeshRenderer> someRenderer;
现在,你的 MeshRenderer 引用即使在对象池导致其断开后,也会被自动修复。
4、网络模块 (Network Modules)
NetworkModule 是 PurrNet 网络解决方案中的一个基类,允许你创建模块化、具有网络感知能力的组件。这些模块不是 MonoBehaviour,可以被附加到任何 NetworkIdentity 上,从而实现代码复用和清晰的关注点分离。
主要特性
- Modularity模块化:将特定功能封装到可复用的模块中。
- Network Integration网络集成:访问网络属性,如
isServer、isClient,并能发送 RPC。 - Lifecycle Hooks生命周期钩子:重写
OnSpawn和OnDespawned等方法进行初始化和清理。
4.1 示例:具有状态同步的生命值模块
这是一个简洁的生命值模块示例,它在网络上同步生命值,确保客户端重连时也能获得正确的状态。
using PurrNet;
using PurrNet.Transports;
using System;
public class HealthModule : NetworkModule
{
private float _health;
public event Action<float> OnHealthChanged;
public float Health
{
get => _health;
set
{
if (!isServer) return; // 只有服务器可以修改
// 与所有客户端同步生命值
RpcUpdateHealth(value);
}
}
public HealthModule(float initialHealth)
{
_health = initialHealth;
}
public override void OnSpawn()
{
base.OnSpawn();
if (isServer)
{
// 确保新加入或重连的客户端收到当前生命值
RpcUpdateHealth(_health);
}
}
[ObserversRPC(Channel.Reliable, bufferLast: true, runLocally: true)]
private void RpcUpdateHealth(float healthValue)
{
_health = healthValue;
OnHealthChanged?.Invoke(_health);
}
}
解释
- 缓冲 RPC:
RpcUpdateHealth方法使用bufferLast: true来缓冲最后的生命值,以便新加入或重连的客户端能获得正确的状态。 - 服务器权威:只有服务器可以修改
Health属性,以维护权威的游戏状态。 - 事件处理:客户端可以订阅
OnHealthChanged事件来响应生命值更新。
将 HealthModule 附加到 NetworkIdentity
using UnityEngine;
using PurrNet;
public class Player : NetworkIdentity
{
private HealthModule healthModule = new HealthModule(100); // 默认 100 点生命值
public override void OnSpawn()
{
healthModule.OnHealthChanged += OnHealthChanged;
}
public override void OnDespawned()
{
healthModule.OnHealthChanged -= OnHealthChanged;
}
private void OnHealthChanged(float newHealth)
{
// 更新UI或其他客户端逻辑
Debug.Log($"玩家生命值现在是 {newHealth}");
}
public void TakeDamage(float damage)
{
if (!isServer) return; // 只有服务器能处理伤害
healthModule.Health -= damage;
if (healthModule.Health <= 0)
{
// 处理玩家死亡
Debug.Log("玩家已死亡。");
}
}
}
4.2 常见陷阱
1. 模块必须是字段或属性
网络模块必须在 NetworkIdentity 上声明为字段或属性。
不要动态创建它们或将它们存储在集合中。
public class MyNetworkObject : NetworkIdentity
{
public SyncVar<int> health; // ✅ 正确
private SyncList<string> names; // ✅ 正确
// ❌ 错误:不要使用模块的列表
public List<SyncVar<int>> invalidList;
}
2. 编辑器序列化
模块可以被序列化并显示在 Unity 编辑器中。
只需用 [SerializeField] 标记它们或将它们设为 public。
[SerializeField]
private SyncVar<int> health; // 将在 Inspector 中显示
public SyncList<string> names; // 如果是 public 也会显示
3. 初始化:使用 OnInitializeModules()
你可以在 protected virtual void OnInitializeModules() 回调中创建、重写或初始化模块。
此方法在网络发送数据或分配 ID之前运行。
protected override void OnInitializeModules()
{
health = new SyncVar<int>(100);
// 在这里进行自定义初始化
}
4. 生成后不要替换模块
一旦生成,切勿将新的模块实例分配给字段/属性。
这样做会破坏网络功能或导致未定义行为。
模块在生成后应该是恒定不变的。
// ❌ 生成后不要这样做:
health = new SyncVar<int>(200); // 这会破坏网络!
5. 嵌套模块
你可以嵌套模块——这意味着一个网络模块可以使用其他模块(如 SyncVar)作为字段或属性。
public class MyModule : NetworkModule
{
public SyncVar<int> score; // ✅ 允许
}
**不要创建循环依赖。**\
如果模块 A 使用模块 B,而模块 B 又使用模块 A,这会破坏功能。\
嵌套必须形成一个树状结构,而不是循环。
```csharp
// ❌ 不允许:循环引用
public class ModuleA : NetworkModule
{
public ModuleB b;
}
public class ModuleB : NetworkModule
{
public ModuleA a; // 这会形成循环,导致编译错误
}
6. 不要使用模块列表
你不能创建模块的列表或数组。
模块必须在编译时声明为字段或属性。
// ❌ 不允许:
public List<SyncVar<int>> moduleList;
// ✅ 允许:
public SyncVar<int> health;
public SyncVar<int> mana;
7. 模块事件
模块具有与 NetworkIdentity 类似的事件,例如:
OnSpawnedOnOwnerChangedOnDespawned
你可以在模块内部使用这些事件来实现自定义逻辑。
public class MyModule : NetworkModule
{
protected override void OnSpawned() { /* ... */ }
protected override void OnOwnerChanged(..) { /* ... */ }
protected override void OnDespawned() { /* ... */ }
}
4.3 同步类型 (Sync Types)
所有同步类型(例如 SyncVar、SyncList)都构建在我们的网络模块系统之上。NetworkModule 的任何限制或功能同样适用于它们。
这意味着你可以创建自己的同步类型或模块——SyncVars 只是网络模块,而不是魔法。包括你在内的任何人都可以使用相同的系统构建自定义模块。
1. SyncVar
同步变量,通常称为 “SyncVar”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步脚本变量。
SyncVars 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
private SyncVar<int> mySync = new(2); // 现在默认值为 2
private SyncVar<int> myOtherSync = new(5, ownerAuth: true); // 默认值为 5,并且是所有者授权
protected override void OnSpawned(bool asServer)
{
mySync.onChanged += OnMySyncChange;
if (asServer)
mySync.value = 420;
if (isOwner)
myOtherSync.value = 69;
}
private void OnMySyncChange(int newValue)
{
Debug.Log("SyncVar 已更改为:" + newValue);
}
创建 SyncVar 时,你可以做几件事:
- 不提供参数:
new(); - 提供默认值:
new(5);// 对于数字类型 - 提供设置,如
ownerAuth或sendIntervalInSeconds
2. Validated SyncVar
这是 SyncVar 的一个更高级的变体,允许你在客户端响应性和服务器授权变更之间获得两全其美的效果。
此模块自动设置为所有者授权,并绑定到它所在的 NetworkIdentity。
Validated SyncVar 的理念很简单:
- 客户端进行本地更改并立即获得响应
- 服务器收到此更改通知并可以验证更改是否应该被接受
- 如果更改被验证,则发送给所有人
- 如果更改未被验证,则回滚,所有者会收到一个回调
这使你可以轻松地为 Validated SyncVar 添加防作弊功能,同时在公平/有效的情况下保持其完全响应性。
以下是一个简单的使用示例,只允许值向上计数,不允许向下计数:
private ValidatedSyncVar<int> _testVar = new();
private void OnEnable()
{
_testVar.serverValidation += ServerValidator;
_testVar.onValidationFail += OnValidationFail;
_testVar.onChangedWithOld += OnChanged;
}
private void OnDisable()
{
_testVar.serverValidation -= ServerValidator;
_testVar.onValidationFail -= OnValidationFail;
_testVar.onChangedWithOld -= OnChanged;
}
private bool ServerValidator(int oldValue, int newValue)
{
// 只有服务器会执行到这里。每当发生更改时调用。
if (oldValue > newValue)
return false;
return true;
}
private void OnValidationFail(int failedValue, int authoritativeValue)
{
// 如果验证失败,只有所有者会收到此通知
Debug.Log($"验证失败 {failedValue}。回滚到 {authoritativeValue}");
}
private void OnChanged(int oldValue, int newValue, bool validated)
{
// 对于所有者,这会立即调用,validated = false
// 如果通过验证,所有人收到时 validated = true
Debug.Log($"值已更改:{oldValue} -> {newValue} | 已验证:{validated}");
}
3. SyncList
同步列表,通常称为 “SyncList”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步列表内容。
使用 SyncLists 就像使用常规列表一样简单,只需注意谁对它拥有权限。
SyncLists 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建列表的新实例 - `true` 表示它是所有者授权
[SerializeField] private SyncList<int> myList = new(true);
protected override void OnSpawned()
{
// 订阅列表的更改事件
myList.onChanged += OnListChanged;
}
private void OnListChanged(SyncListChange<int> change)
{
// 当列表更改时,每个人都会调用此方法。
// 它将记录值、索引和操作
Debug.Log($"列表已更新:{change}");
}
private void ChangeMyList()
{
// 这将更改或添加一个值
myList[0] = 69;
// 这将移除该值
myList.Remove(69);
// 这将移除给定索引处的条目
myList.RemoveAt(0);
// 这将清空列表
myList.Clear();
// 这将在给定索引处插入一个值
myList.Insert(1, 420f);
// 这将标记索引为脏
myList.SetDirty(0);
}
SyncList 在编辑器中可序列化,但你不应该在此处编辑它。这纯粹是为了视觉调试。

SyncListChange
使用 onChanged 回调中的 SyncListChange 时,你可以获得一些基本信息:
.operation告诉你更改的类型,可以是:Added(添加)、Removed(移除)、Insert(插入)、Set(设置)、Cleared(清空)。.value是已更改的新值。类型为 T,与 SyncList 的类型匹配。.index告诉你此更改在哪个索引处相关。
4. SyncArray
同步数组,通常称为 “SyncArray”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步数组内容。
使用 SyncArrays 就像使用常规数组一样简单,只需注意谁对它拥有权限。
SyncArrays 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建数组的新实例
// 20 设置数组的初始长度
// `true` 表示它是所有者授权
public SyncArray<int> syncArray = new(20, true);
protected override void OnSpawned()
{
// 订阅数组的更改事件
syncArray.onChanged += OnArrayChange;
}
private void OnArrayChange(SyncArrayChange<int> change)
{
// 当数组更改时,每个人现在都会调用此方法。
// 它将记录值、索引和操作
Debug.Log(change);
}
private void ChangeMyArray()
{
// 这将更改或添加一个值
syncArray[0] = 69;
// 将数组大小调整为 15 个元素
syncArray.Length = 15;
}
SyncArrayChange
使用 onChanged 回调中的 SyncArrayChange 时,你可以获得一些基本信息:
.operation告诉你更改的类型,可以是:Set(设置)、Cleared(清空)、Resized(调整大小)。.value是已更改的新值。类型为 T,与 SyncArray 的类型匹配。.index告诉你此更改在哪个索引处相关。
5. SyncQueue
同步队列,通常称为 “SyncQueue”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步队列内容。
使用 SyncQueue 就像使用常规队列一样简单,只需注意谁对它拥有权限。
SyncQueue 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建队列的新实例 - `true` 表示它是所有者授权
[SerializeField] private SyncQueue<int> myQueue = new(true);
protected override void OnSpawned()
{
// 订阅队列的更改事件
myQueue.onChanged += OnQueueChanged;
}
private void OnQueueChanged(SyncQueueChange<int> change)
{
// 当队列更改时,每个人都会调用此方法。
// 它将记录值和操作
Debug.Log($"队列已更新:{change}");
}
private void ChangeMyQueue()
{
// 这将元素入队
myQueue.Enqueue(69);
// 这将使队列中的第一个元素出队
myQueue.Dequeue();
// 这将清空队列
myQueue.Clear();
// 这将查看第一个元素
var myVal = myQueue.Peek();
}
SyncQueue 在编辑器中被序列化。它还会尝试通过获取 ToString() 来序列化不可序列化的类/结构。
SyncQueueChange
使用 onChanged 回调中的 SyncQueueChange 时,你可以获得一些基本信息:
.operation告诉你更改的类型,可以是:Enqueued(入队)、Dequeued(出队)、Cleared(清空)。.value是已更改的新值。类型为 T,与 SyncQueue 的类型匹配。
6. SyncDictionary
同步字典,通常称为 “SyncDictionary”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步字典。
理论上,你只需将普通字典转换为 SyncDictionary,它就应该可以工作。使用方式完全相同,包括添加、设置、移除或清空。
SyncDictionaries 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建字典的新实例 - `true` 表示它是所有者授权
[SerializeField] private SyncDictionary<int, float> myDictionary = new(true);
protected override void OnSpawned()
{
// 订阅字典的更改事件
myDictionary.onChanged += OnDictionaryChanged;
}
private void OnDictionaryChanged(SyncDictionaryChange<int, float> change)
{
// 当字典更改时,每个人都会调用此方法。
// 它将记录键、值和操作
Debug.Log($"字典已更新:{change}");
}
private void ChangeMyDictionary()
{
// 这将更改或添加一个值到字典中
myDictionary[123] = 0.69f;
// 这将从字典中移除该值
myDictionary.Remove(123);
// 这将清空字典
myDictionary.Clear();
// 这将标记键为脏
myDictionary.SetDirty(123);
}
SyncDictionary 在编辑器中进行自定义序列化,以便于视觉调试。

SyncDictionaryChange
使用 onChanged 回调中的 SyncDictionaryChange 时,你可以获得一些基本信息:
.operation告诉你更改的类型,可以是:Set(设置)、Cleared(清空)。.key告诉你更改发生在哪个键上。类型为 TKey,与 SyncDictionary 的键类型匹配。.value是已更改的新值。类型为 TValue,与 SyncDictionary 的值类型匹配。
7. SyncEvent
同步事件,通常称为 “SyncEvent”,可以在你的代码中轻松定义,并会自动调用所有正在监听该事件的 玩家 的事件。
使用 SyncEvent 就像使用常规 UnityEvent 一样简单,只需注意谁对它拥有权限。
SyncEvents 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建事件实例 - True 表示它是所有者授权
[SerializeField] private SyncEvent<int> syncEvent = new(true);
protected override void OnSpawned()
{
// 监听事件
syncEvent.AddListener(SyncEventTest);
}
private void SyncEventTest(int myValue)
{
// 当所有者调用它时,所有订阅了 syncEvent 的人都会收到这个值
Debug.Log($"从 SyncEvent 接收到的值:{myValue}");
}
public void InvokeSyncEvent()
{
// 因为事件是所有者授权的,只有所有者可以调用事件。
if (!isOwner)
return;
// 所有者调用事件,值为 10
syncEvent.Invoke(10);
}
SyncEvent 在 Unity 编辑器中作为 Unity 事件可序列化,这意味着你可以轻松地从 Inspector 视图添加事件。

8. SyncHashset
同步 HashSet,通常称为 “SyncHashSet”,可以在你的代码中轻松定义,并会自动在所有 玩家 之间同步 HashSet 内容。
使用 SyncHashSet 就像使用常规 HashSet 一样简单,只需注意谁对它拥有权限。
SyncHashSet 基于网络模块系统构建,这意味着你必须初始化它。以下是使用示例:
// 创建 HashSet 的新实例 - `true` 表示它是所有者授权
[SerializeField] private SyncHashSet<string> myHashSet = new(true);
protected override void OnSpawned()
{
// 订阅 HashSet 的更改事件
myHashSet.onChanged += OnHashSetChanged;
}
private void OnHashSetChanged(SyncHashSetChange<string> change)
{
// 当 HashSet 更改时,每个人都会调用此方法。
// 它将记录值和操作
Debug.Log($"HashSet 已更新:{change}");
}
HashSet 在 Unity 编辑器中被序列化为一个列表,以便于调试。你不能在此处修改值。

9. SyncTimer
SyncTimer 允许轻松同步自动倒计时的计时器。
你可以轻松执行开始、停止、暂停和恢复计时器等操作。大多数可访问的方法应该相当直观。
SyncTimer 还会自动处理计时器的协调,这意味着它会强制对齐所有客户端,以防止出现不同步的情况。频率越高,精度就越高,但使用的数据也越多。通常它非常轻量,所以如果必要,不要害怕降低这个数字。
以下是 SyncTimer 的使用示例,它是服务器授权(默认)的,协调间隔为 3(默认):
public TMP_Text timerText;
// false = 所有者授权
// 3 = 协调间隔
// 协调间隔决定它强制对齐所有客户端的频率
private SyncTimer timer = new();
private void Awake()
{
// onTimerSecondTick 每秒调用一次
timer.onTimerSecondTick += OnTimerSecondTick;
}
protected override void OnSpawned(bool asServer)
{
// 这以 30 秒倒计时启动计时器
if(isOwner)
timer.StartTimer(30f);
}
private void OnTimerSecondTick()
{
// 你也可以使用 .remaining 来获取精确的浮点值
// 对于显示计时器,remainingInt 使其变得简单
timerText.text = timer.remainingInt.ToString();
}
private void PauseGameTimer()
{
// 暂停计时器,并将剩余时间同步,因为设置为 true
timer.PauseTimer(true);
}
private void ResumeGameTimer()
{
// 将从暂停的位置恢复计时器
timer.ResumeTimer();
}
除了使用 SyncTimer 的自动倒计时功能,你还可以用你喜欢的任何增量手动推进计时器,让你可以更自由地向上计数、向下计数、加快或减慢速度。
它的基本设置如下:
private SyncTimer _timer = new(manualUpdate: true);
private void Update()
{
_timer.Advance(Time.deltaTime);
}
10. SyncInput
SyncInput 同步类型适用于所有者授权的输入同步和服务器授权的游戏逻辑。这听起来可能很高级,但实际上非常简单!
理念是:服务器负责所有游戏逻辑,只需从客户端向服务器发送必要的输入,然后服务器将使用它。
使用 RPC 很容易做到这一点,但如果使用太多不必要的输入调用和太多数据,很容易变得性能不佳。幸运的是,PurrNet 提供了一个超级易于使用的模块来处理这个问题!
为什么要使用输入同步?
在多人游戏中,处理玩家与玩家之间的交互只有几种好的方式:
- 客户端预测 - 难以使用和理解,但响应性最好
- 输入同步 - 易于使用,但延迟 = 输入延迟
本质上,输入同步的最大优势是工作流程。它非常易于理解和实现,因为所有游戏逻辑只需在服务器上运行并传达给客户端。这就是整个理念。以下游戏使用了这种方法:
- Gang Beasts
- Totally Accurate Battle Simulator (TABS) 多人模式
- Human: Fall Flat
- Stick Fight: The Game
类型
SyncInput<T> 接受任何非托管或实现了 IEquatable 的类型,这意味着你不仅可以使用简单类型,还可以使用自定义结构体或类,只要它们可以通过等价性设置进行比较。
模拟主机延迟(延迟)
除了从客户端向服务器发送输入外,它还允许主机输入也有模拟延迟的情况。延迟将使主机体验到与客户端类似的感受,以提高整体公平性。
这可以在编辑器或创建 SyncInput 时轻松调整。这以**毫秒(延迟)**定义:
private SyncInput<bool> _mySyncInput = new(defaultValue: false, hostPing: 100f);
如果需要(例如你想匹配客户端的平均延迟),这也可以轻松动态调整。你可以通过以下方式在服务器/主机上轻松设置它:
_mySyncInput.simulatedHostPing = 250f;
简单使用示例
[SerializeField] private SyncInput<Vector2> _input = new();
private void Awake()
{
// 订阅输入更改事件
_input.onChanged += OnInputChanged;
}
private void OnDestroy()
{
// 取消订阅
_input.onChanged -= OnInputChanged;
}
private void OnInputChanged(Vector2 newInput)
{
// 只有服务器会收到这个事件
// 每当输入更改时都会调用该事件
Debug.Log($"新输入:{newInput}");
}
private void Update()
{
if (!isOwner)
return;
// 所有者将持续发送输入 - 只有必要的输入更改会被发送
// 其余部分由 SyncInput 自动过滤
var input = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
_input.value = input;
}
移动示例:
此设置需要 Rigidbody、Collider 和一个所有者授权切换关闭的网络变换组件。这将为你提供可以使用物理进行干净碰撞的玩家!
[SerializeField] private Rigidbody _rigidbody;
[SerializeField] private float _moveSpeed = 5f;
[SerializeField] private float _jumpForce = 5f;
[SerializeField] private SyncInput<Vector2> _moveInput = new();
[SerializeField] private SyncInput<bool> _jumpInput = new();
private bool _jump;
private void Awake()
{
_jumpInput.onChanged += OnJump;
_jumpInput.onSentData += OnSentData;
}
private void OnDestroy()
{
_jumpInput.onChanged -= OnJump;
_jumpInput.onSentData -= OnSentData;
}
private void OnSentData()
{
_jump = false;
}
private void OnJump(bool newInput)
{
if(newInput)
_rigidbody.AddForce(Vector3.up * _jumpForce, ForceMode.Impulse);
}
private void Update()
{
if (!isOwner)
return;
_moveInput.value = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
if(!_jump)
_jump = Input.GetKeyDown(KeyCode.Space);
_jumpInput.value = _jump;
}
private void FixedUpdate()
{
if (!isServer)
return;
Vector3 move = new Vector3(_moveInput.value.x, 0, _moveInput.value.y).normalized;
_rigidbody.AddForce(move * _moveSpeed);
}
4.4 SyncBigData
SyncBigData 是一个可以单独使用但也为其他与向其他玩家发送大量数据相关的模块提供支持的模块。
总体思路很简单:你给它一个大字节数组,指定它是否是所有者授权,以及你希望它使用多少 KB/s,然后它会自动完成剩下的工作。这自然使其成为异步的,你提供的数据需要时间到达,接收者需要等待它准备好。
所有权变更和重连也能优雅地处理:
- 如果在传输过程中失去所有权,则传输被取消,并保留之前有效的数据。
- 如果所有者断开连接并重新连接(使用 Cookie 会话),他们将开始从服务器下载最新状态,如果这是一个图像,例如,他们将检索最后发送的相同图像。
SyncTextureFile

定义模块:
[SerializeField] private SyncTextureFile _avatar;
作为控制器设置数据:
_avatar.filePath = _path;
响应接收到的新数据:
private void OnEnable()
{
_avatar.onDataChanged += OnAvatarChanged;
}
private void OnDisable()
{
_avatar.onDataChanged -= OnAvatarChanged;
}
private void OnAvatarChanged(Texture2D texture)
{
_avatarImage.texture = texture;
}
5、远程过程调用 (Remote Procedure Calls)
简单来说,RPC 允许你在另一台设备/机器上调用一个方法。如果你在客户端上成功调用了 ServerRpc,该方法将在服务器机器上执行,而不是在实际调用方法的本地机器上执行。这使得通过网络与其他机器交互变得非常容易。
调用 RPC 很简单
为了调用 RPC,你必须确保你的脚本至少继承自 Network Identity,但建议你继承 Network Behaviour 以获得更多功能。
RPC 逻辑可能依赖于所有权,这可以在你的 Network Rules 中进行修改。
示例脚本 NetworkTest:
using PurrNet;
public class NetworkTest : NetworkBehaviour
{
// OnSpawned 会在对象首次被网络看到时调用。
// 如果你是主机,这个 OnSpawned 会被调用两次,一次作为服务器,一次作为客户端
protected override void OnSpawned(bool asServer)
{
if (asServer)
return;
// 如果你的网络规则设置为所有人都可以调用 ServerRpc,那么这可以从所有客户端调用
// 或者,它只能由该身份的所有者调用
TestServerMethod();
}
[ServerRpc]
private void TestServerMethod()
{
// 这段代码将在服务器上执行。
// 服务器现在在所有观察的客户端上调用观察者 RPC 方法。
// 如果你的网络规则允许,客户端也可以调用这个方法,从而跳过服务器 RPC
TestObserverMethod();
}
[ObserversRpc]
private void TestObserverMethod()
{
// 这段代码将在每个观察客户端机器上执行。
// 这是用相同信息更新所有观察客户端的好方法。
}
[TargetRpc]
private void TestTargetMethod(PlayerID target)
{
// 这段代码只会在目标的本地实例上调用
// 这是更新目标客户端的好方法
}
}
RPC 的类型
有 3 种不同类型的 RPC:
- ServerRPC(服务器 RPC)
- ObserversRPC(观察者 RPC)
- TargetRPC(目标 RPC)
不同的 RPC 有不同的参数可以设置,例如:
[ServerRpc(RequireOwnership: false, RunLocally: true)]
下面你将找到每种 RPC 类型及其各自的参数。
ServerRPC(服务器 RPC):
ServerRPC 会进行 客户端 -> 服务器 的调用,这意味着你在客户端上执行的方法,将在服务器上运行。
它包含以下参数:
RequireOwnership- 这将覆盖 Network Rules 或 Network Identity 检视器中的任何设置。使用此参数,你可以修改调用 RPC 是否需要所有权。RunLocally- 默认情况下,这是 false,但是,如果你将其覆盖为 true,则意味着调用者也会在本地运行该逻辑。
ObserversRPC(观察者 RPC):
ObserversRPC 会进行 服务器 -> 所有客户端 的调用,如果你的 网络规则 允许,也可以是 客户端 -> 所有客户端。这意味着被调用的方法将为每个客户端触发。
它包含以下参数:
RequireServer- 这将覆盖 Network Rules 中关于客户端是否可以直接调用 ObserversRpc 的任何设置。BufferLast- 如果设置为 true,当一个新的客户端加入时,他们将获得带有参数内数据的该方法最近一次调用。RunLocally- 如果设置为 true,调用者将在本地运行方法逻辑,绕过网络路径。如果是服务器调用,服务器也会运行该方法。如果是客户端调用,客户端将立即运行该方法,而不是等待服务器的调用。
TargetRPC(目标 RPC):
TargetRPC 会进行 服务器 -> 客户端 的调用,如果你的 网络规则 允许,也可以是 客户端 -> 客户端。这意味着被调用的方法,将只在给定的 PlayerID 的客户端上触发。
它包含以下参数:
RequireServer- 这将覆盖 Network Rules 中关于客户端是否可以直接调用 ObserversRpc 的任何设置。BufferLast- 如果设置为 true,当目标客户端再次加入时(我们只有在之前见过他们才能保存他们的数据),他们将获得带有参数内数据的该方法最近一次调用。RunLocally- 如果设置为 true,调用者将在本地运行方法逻辑,绕过网络路径。如果是服务器调用,服务器也会运行该方法。如果是客户端调用,客户端将立即运行该方法,而不是等待服务器的调用。
RPC 信息 (RPC Info)
RPC Info 是一个非常有用的工具,用于获取刚刚发送的 RPC 的信息。
你只需将 RPCInfo 作为 RPC 的最后一个参数添加,并将其默认值设为 default,它就会在收到 RPC 时自动填充。
[ServerRpc]
private void TestRpc(int myValue, RPCInfo info = default)
{
// 现在我们有了 RPC 信息
Debug.Log($"发送者:{info.sender}")
}
这个值主要用于获取发送者。
5.1 泛型 RPC (Generic RPC)
C# 中的泛型对于创建模块化和可扩展的系统非常棒,我们不想让 PurrNet 在这方面限制你。
这适用于所有 RPC 类型,例如:ServerRpc、ObserversRpc、TargetRPC。它也适用于静态 RPC 和可等待 RPC!
下面是一个使用泛型 RPC 的超简单示例。就这么简单!
private void SendFirst()
{
TestRpc("Purrfect!", true);
}
private void SendSecond()
{
TestRpc(69, 4.20f);
}
[ServerRpc]
private void TestRpc<T, P>(T myData1, P myData2)
{
Debug.Log($"收到 {myData1},类型为:{myData1.GetType()}");
Debug.Log($"收到 {myData2},类型为:{myData2.GetType()}");
}
5.2 静态 RPC (Static RPC)
在你的开发流程中,使用静态方法非常常见,为什么在网络编程中不能也一样呢!
请注意,如果你通过它运行游戏逻辑,这可能是不安全的,因为我们没有 network identity 来检查所有权。
这适用于所有 RPC 类型,例如:ServerRpc、ObserversRPC、TargetRPC。它也适用于泛型 RPC 和可等待 RPC!
private void SendData()
{
TestRpc(123);
}
[ServerRpc]
private static void TestRpc(int myNumber)
{
Debug.Log($"收到 {myNumber}");
}
5.3 可等待 RPC (Awaitable RPC)
可等待 RPC 是网络编程和优化开发的革命性功能,它很好地与 C# 的自然工作流程集成。
它们利用了 Tasks,也支持 UniTask。可选地,它们也可以被设置为异步。
它不仅允许你等待 RPC 逻辑完成,还允许你从 RPC 的接收端返回值。
以下是一个真实用例示例,展示了玩家如何在服务器上设置准备就绪,并返回 true 表示所有玩家都准备好,false 表示并非所有玩家都准备好(由 CheckForReady 方法处理)。
它还内置了一个故障安全机制,以防这不属于我们当前的网络化状态机设置的状态:
// asyncTimeoutInSec 是调用者应该等待响应的时间。
// 在本例中,它会在 10 秒后失败(默认:5 秒)
[ServerRpc(requireOwnership: false, asyncTimeoutInSec: 10)]
public async Task<bool> SetReady(RPCInfo info = default)
{
if (machine.currentStateNode != this)
return false;
if(!_readyPlayers.Contains(info.sender))
_readyPlayers.Add(info.sender);
return CheckForReady();
}
对于客户端来说,所需要做的就是有一个可以等待 RPC 结果的异步方法。你可以在这里了解更多关于 async 关键字的信息。本质上,它允许该方法以异步方式运行,与你原本完全顺序的代码分开,使其能够暂停执行,直到收到响应。
private async void OnReady()
{
var allReady = await shopPhase.SetReady();
// 如果所有人都准备好了,服务器会处理状态切换,我们就不需要做其他事了
if (allReady)
return;
ShowWaitingScreen();
}
基础示例
以下是使用此设置的最低限度示例:
private async void MyLocalMethod()
{
var myBool = await TestBool();
if (!myBool)
return;
Debug.Log("耶,我们成功了!");
}
[ServerRpc(requireOwnership: false)]
public async Task<bool> TestBool()
{
return true;
}
5.4 RPC 的本地直接执行
PurrNet 允许你在本地上下文中直接执行 RPC,而无需通过网络发送。当你想要绕过网络传输并在本地处理逻辑时,这会很有用。
使用编译器标志进行作用域内本地执行
要在一个方法的特定部分内本地执行 RPC,请使用 PurrCompilerFlags.EnterLocalExecution() 和 PurrCompilerFlags.ExitLocalExecution()。
[ObserversRpc]
public void PerformAction()
{
PurrCompilerFlags.EnterLocalExecution();
// 此处调用的任何 RPC 都将绕过 PurrNet
// 它们将在本地调用,不通过网络发送
PurrCompilerFlags.ExitLocalExecution();
// 这里我们又回到了通过网络发送 RPC 的状态
}
- 作用:只有
EnterLocalExecution和ExitLocalExecution调用之间的代码在本地运行。方法的其余部分作为正常的 RPC 运行。
使用 LocalMode 进行完整方法的本地执行
要使整个方法在本地执行,请使用 LocalMode 属性。
[ObserversRpc, LocalMode]
public void PerformAction()
{
Debug.Log("整个方法都在本地运行,不发送 RPC。");
}
- 作用:该方法在本地运行,不通过网络发送任何数据。
使用场景
- 作用域内本地执行:如果只有方法的特定部分需要绕过网络,请使用此方式。
- 完整方法本地执行:当整个方法都应该是本地执行时,请使用此方式。
使用这些选项,你可以高效且灵活地处理 RPC 的本地执行。
免责声明
这些是 PurrNet 用于确定代码应如何行为的编译器提示。它们:
- 不直接执行或改变任何逻辑。
- 作用范围仅限于当前函数。它们不会传播到其他函数调用。
- 不跟踪代码的逻辑流程。例如,你可以在
if语句内开始本地执行,然后在if块外结束本地执行。在这种情况下,EnterLocalExecution和ExitLocalExecution之间的每条指令仍将被处理,即使if条件不成立。
请谨慎使用这些工具,以确保正确的执行并避免意外行为。
5.5 前/后处理器 (Pre/Post Processors)
本着模块化的精神,我们为你提供了一些处理器,你可以注册它们来处理 RPC 发送前后发生的事情。它们看起来像这样:
public class RPCModule
{
public delegate void RPCPreProcessDelegate(ref ByteData rpcData, RPCSignature signature, ref BitPacker packer);
public delegate void RPCPostProcessDelegate(ByteData rpcData, RPCInfo info, ref BitPacker packer);
public static event RPCPreProcessDelegate onPreProcessRpc;
public static event RPCPostProcessDelegate onPostProcessRpc;
}
这些可以用于追加、拦截、更改或编辑进出 RPC 流量的数据。我们在内部使用它进行压缩,你可能会有其他用途。
6、广播 (Broadcasts)
在网络系统(如 PurrNet)中,广播允许你轻松地发送和接收数据,而无需网络身份标识。在某些系统和情况下它很有用,但对于大多数设置来说,使用 RPC 会同样好用且更简单。
要使用广播,你需要三样东西:
- 要发送的自定义数据结构
- 订阅广播事件
- 发送数据
客户端只能直接向服务器广播,而服务器/主机可以向所有人广播。
一个基本的广播设置看起来像这样:
private void Start()
{
// 我们订阅接收这种类型的数据
InstanceHandler.NetworkManager.Subscribe<DataToSend>(OnReceiveData);
// 下面是一个示例,展示如何仅作为服务器订阅(false 表示客户端)
// 这可能会给你更多控制权
// InstanceHandler.NetworkManager.Subscribe<DataToSend>(OnReceiveData, true);
}
private void OnDestroy()
{
// 记得取消订阅,以避免可能的错误和问题
InstanceHandler.NetworkManager.Unsubscribe<DataToSend>(OnReceiveData);
}
private void Update()
{
// 按下 1 键发送数据
if (Input.GetKeyDown(KeyCode.Alpha1))
{
if (InstanceHandler.NetworkManager.isServer)
{
// 如果我们是服务器,我们构建数据并发送给每个玩家
var data = new DataToSend()
{
intValue = 69,
boolValue = false
};
InstanceHandler.NetworkManager.SendToAll(data);
}
else
{
// 如果我们不是服务器,我们发送一些数据到服务器
var data = new DataToSend()
{
intValue = 420,
boolValue = true
};
InstanceHandler.NetworkManager.SendToServer(data);
}
}
}
private void OnReceiveData(PlayerID player, DataToSend data, bool asServer)
{
// 我们现在已收到数据
Debug.Log($"从玩家 {player} 收到数据:{data.intValue}, {data.boolValue}");
}
// 我们需要知道我们在发送什么数据类型
private struct DataToSend : IPackedAuto
{
public int intValue;
public bool boolValue;
}
核心概念解释
广播是一种简单的网络通信方式:
- 直接通信:不需要将脚本挂载到游戏对象上
- 轻量级:适合发送简单的状态信息或命令
- 一对多:服务器可以向所有客户端发送,客户端只能向服务器发送
使用场景
- 游戏状态更新(如"游戏开始")
- 玩家匹配信息
- 简单的聊天消息
- 不需要复杂同步的全局事件
注意事项
- 记得取消订阅:在
OnDestroy中取消订阅,防止内存泄漏 - 数据结构:需要实现
IPackedAuto接口,以便网络系统能序列化/反序列化 - 权限控制:客户端只能发给服务器,服务器可以发给所有人
与 RPC 的区别
- 广播:更简单,适合独立的事件/消息
- RPC:功能更强大,可以附加到特定游戏对象,支持所有权检查等
选择哪种方式取决于你的具体需求。对于简单的全局通知,广播更轻便;对于与具体游戏对象相关的操作,RPC 更合适。
7、场景管理 (Scene Management)
在 PurrNet 中使用场景模块就像使用 Unity 默认的场景管理器切换场景一样简单。但要理解玩家在场景中的逻辑,还需要了解一些更深层的知识。
场景管理需要在服务器端处理,所以下面的所有内容都需要在这个上下文下理解!
加载场景
为方便使用,你可以简单地调用 LoadSceneAsync 并传入场景名称字符串或场景ID。
// 使用默认设置加载场景
networkManager.sceneModule.LoadSceneAsync("sceneName");
LoadSceneAsync 方法有多个重载版本(见下图),例如直接设置加载模式、使用 Unity 的 LoadSceneParameters,或添加自定义的 PurrNet 设置。
LoadSceneAsync 的重载方法
你也可以使用名为 PurrSceneSettings 的自定义场景设置来加载场景,见下例:
// 使用自定义设置加载场景
var settings = new PurrSceneSettings();
settings.isPublic = true; // 默认设置 - 所有连接自动切换场景
settings.mode = LoadSceneMode.Single; // 默认设置 - 卸载所有其他场景
networkManager.sceneModule.LoadSceneAsync("sceneName", settings);
PurrSceneSettings
PurrSceneSettings 是一个用于场景加载设置的结构体。包含以下值:
- mode:Unity 的 SceneLoadMode,决定应该是叠加加载还是单场景加载
- physicsMode:加载时使用的 Unity LocalPhysicsMode
- isPublic:是否自动将所有玩家拉入场景
卸载场景
使用 PurrNet 卸载场景就像调用场景卸载一样简单,类似于 Unity 自己的场景管理。这将为场景中的所有客户端以及服务器卸载该场景。
networkManager.sceneModule.UnloadSceneAsync("sceneName");
UnloadSceneAsync 的重载方法
UnloadSceneOptions 是一个 Unity 类,在使用 UnloadSceneAsync 时默认值为 None。
重要注意事项
1. 服务器端控制
所有场景加载和卸载操作都必须在服务器端进行。客户端不能直接控制场景切换。
2. 场景同步
当服务器加载或卸载场景时,所有连接的客户端都会自动同步这些变化。
3. 使用场景模式
- Single:卸载所有其他场景,只保留新加载的场景
- Additive:在现有场景基础上叠加新场景
4. 公共与私有场景
- isPublic = true:所有玩家自动进入该场景
- isPublic = false:需要手动管理哪些玩家进入该场景
5. 物理模式
可以根据需要为场景设置不同的物理模拟模式,这对于复杂的多场景物理交互特别有用。
6. 异步操作
所有场景操作都是异步的,这意味着它们不会阻塞主线程,你可以在加载过程中显示加载界面。
实际应用示例
// 服务器端代码示例
public class GameManager : NetworkBehaviour
{
[SerializeField] private NetworkManager networkManager;
public void LoadGameScene()
{
if (!isServer) return; // 确保只在服务器端执行
var settings = new PurrSceneSettings
{
mode = LoadSceneMode.Single,
isPublic = true, // 所有玩家都进入游戏场景
physicsMode = LocalPhysicsMode.Physics3D
};
networkManager.sceneModule.LoadSceneAsync("GameScene", settings);
}
public void LoadLobbyScene()
{
if (!isServer) return;
// 返回大厅场景
networkManager.sceneModule.LoadSceneAsync("Lobby");
}
}
记住:场景管理是多人游戏体验的关键部分,合理的场景切换策略可以大大提升游戏体验!
8、实例处理器 (Instance Handler)
实例处理器让你可以轻松地从任何地方访问 Network Manager,即使是在非 Network Identity 或 Network Behaviour 的脚本中:
var nm = InstanceHandler.NetworkManager; // 在任何地方获取网络管理器
但更重要的是,它允许你轻松地注册、注销和获取你自己的实例!
下面是一个注册和注销实例的示例,以及从静态方法使用这些实例的示例。请注意,你也可以从其他脚本获取实例,无论它们是否是 MonoBehaviour。
public class GameManager : NetworkBehaviour
{
private void Awake()
{
// 我们注册 GameManager 实例
InstanceHandler.RegisterInstance(this);
}
private void OnDestroy()
{
// 在被销毁时,我们注销游戏管理器实例
InstanceHandler.UnregisterInstance<GameManager>();
}
private static void GetInstanceExample()
{
// 如果管理器未注册,这将失败
InstanceHandler.GetInstance<GameManager>().Success();
}
private static void TryGetInstanceExample()
{
// 只有在我们获取到管理器时才会运行。如果没有获取到,可以反转 if 语句并记录错误
if(InstanceHandler.TryGetInstance(out GameManager manager))
manager.Success();
}
private void Success()
{
Debug.Log("现在我们从 GameManager 实例中记录日志!", this);
}
}
主要功能说明
1. 全局访问网络管理器
通过 InstanceHandler.NetworkManager,你可以在任何地方轻松访问网络管理器,无需将其作为参数传递或在每个脚本中查找。
2. 自定义实例管理
你可以注册自己的单例或重要管理器,让其他脚本能够轻松访问它们。
3. 两种获取方式
GetInstance<T>():直接获取实例,如果未找到会抛出异常TryGetInstance<T>():尝试获取实例,返回布尔值表示是否成功
使用场景
场景1:游戏管理器单例
// 游戏管理器注册自己
public class GameManager : MonoBehaviour
{
private void Awake()
{
InstanceHandler.RegisterInstance(this);
}
// 其他脚本可以这样访问
public static void SomeStaticMethod()
{
if (InstanceHandler.TryGetInstance(out GameManager gm))
{
gm.StartGame();
}
}
}
场景2:UI管理器
public class UIManager : MonoBehaviour
{
private void Awake()
{
InstanceHandler.RegisterInstance(this);
}
// 从任何地方更新UI
public static void UpdateScore(int score)
{
if (InstanceHandler.TryGetInstance(out UIManager ui))
{
ui.scoreText.text = score.ToString();
}
}
}
场景3:音频管理器
public class AudioManager : MonoBehaviour
{
private void Awake()
{
InstanceHandler.RegisterInstance(this);
}
public static void PlaySound(string soundName)
{
if (InstanceHandler.TryGetInstance(out AudioManager audio))
{
audio.Play(soundName);
}
}
}
重要注意事项
- 注册时机:通常在
Awake()或Start()中注册实例 - 注销时机:在
OnDestroy()中注销实例,避免内存泄漏 - 依赖顺序:确保在需要访问实例之前,实例已经注册
- 避免滥用:只对重要的全局管理器使用此模式,不要为每个小对象都注册实例
最佳实践
// 创建一个安全的访问器属性
public class GameManager : MonoBehaviour
{
private static GameManager instance;
public static GameManager Instance => instance;
private void Awake()
{
if (instance == null)
{
instance = this;
InstanceHandler.RegisterInstance(this);
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
// 提供静态访问方法
public static bool TryGetInstance(out GameManager manager)
{
manager = instance;
return manager != null;
}
}
实例处理器是 PurrNet 中一个强大的工具,它简化了全局访问模式,让多人游戏开发更加整洁和高效!
9、碰撞器回滚(延迟补偿)
什么是碰撞器回滚?
想象一下你在和网上的朋友玩接球游戏,但你扔球的时间和朋友看到球的时间之间有一个小小的延迟。有时候看起来你扔偏了,但在你的屏幕上明明是一个完美的投掷!
碰撞器回滚就像一个时间机器,它能快速回到过去检查:“等等,当我扔球的时候,我的朋友到底站在哪里?” 这在你有一个裁判(服务器)控制每个人可以移动的位置时效果最好——因为裁判确切知道每个人当时真正的位置!
但是如果每个人都可以不经过裁判同意就随意移动(客户端控制的移动),使用这个时间机器就没有太大意义了——因为我们已经信任每个玩家在他们自己屏幕上看到的情况了!
所以回滚主要用于服务器控制所有移动的游戏,比如许多格斗游戏或竞技射击游戏。
你可以把碰撞器回滚理解为服务器考虑你的延迟来验证命中。
为回滚设置碰撞器
添加 ColliderRollback 组件,基本上就完成了。
从这里你有两个选择:要么使用默认设置(自动添加所有子对象包括当前游戏对象的碰撞器),要么手动指定要添加哪些碰撞器。
默认设置
自定义碰撞器列表
这就足以开始记录碰撞器的历史了。
验证命中
在客户端侧
在客户端侧,你需要发送你行动时的时间戳,这可以通过 NetworkIdentity 的 rollbackTick 获得。但你也可以从 NetworkManager 获取:
networkManager.tickModule.rollbackTick
以下是一个在客户端端进行射线投射并发送到服务器验证的示例:
var ray = _camera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out var hit))
{
// 在本地做出反应,如显示命中标记、播放声音、血迹特效等
// 我们应该让服务器处理实际的击杀/伤害
}
ShootOnServer(rollbackTick, ray);
本地(即在客户端侧)你像平时一样进行射线投射,然后简单地将其转发给服务器。在这个场景中,ShootOnServer 是一个 ServerRpc。
在服务器侧
现在让我们在服务器上验证这个命中!这将需要重新发射相同的射线,考虑时间差,如下所示:
[ServerRpc(requireOwnership: false)]
private void Shoot(double preciseTick, Ray ray)
{
if (rollbackModule.Raycast(preciseTick, ray, out var hit))
{
// 处理命中,比如伤害玩家等
}
}
注意我们没有使用 Physics.Raycast,这是因为我们应该通过我们的模块进行射线投射,以正确考虑游戏状态。
与其他系统不同,我们不会物理地移动碰撞器,因为这可能会触发不需要的物理事件,如果不加处理会导致奇怪的错误。相反,我们在每个碰撞器的基础上弯曲射线,并通过这种方式解决命中问题,这样你就不必担心碰撞器的移动问题。
通俗解释
用现实生活例子理解:
假设你和朋友视频通话玩"空中接球":
- 没有回滚:你看到球在3秒前的位置,朋友看到球在现在的位置
- 有回滚:服务器说"等等,让我看看3秒前球在哪里,朋友在哪里,他们真的碰到了吗?"
什么时候需要?
- 射击游戏:你瞄准敌人射击,但敌人有200ms延迟移动了
- 竞技游戏:精确的命中判断很重要
- 服务器权威游戏:服务器决定谁被击中,而不是客户端
什么时候不需要?
- 休闲游戏:精确度不重要
- 客户端预测游戏:每个玩家相信自己的屏幕
- 非竞争性游戏:延迟不是大问题
简单工作流程:
客户端:开枪!(记录时间:12:00:00.500)
↓ 发送给服务器(有200ms延迟)
服务器收到:时间是 12:00:00.700
服务器回滚:计算12:00:00.500时所有玩家的位置
服务器检查:12:00:00.500时,子弹真的击中了吗?
服务器决定:是/否命中
重要提示:
- 只对重要碰撞器使用:不要给所有物体都加,浪费性能
- 服务器必须控制移动:如果客户端自己控制移动,回滚就没意义
- 记录历史:系统需要记住过去一段时间内碰撞器的位置
性能考虑:
系统会记录碰撞器的历史位置,所以:
- 内存使用会增加(存储历史数据)
- CPU使用会增加(计算回滚位置)
- 但比让所有客户端自己决定谁被击中了更公平!
这是一个高级功能,主要用于需要精确命中判断的竞技游戏。对于大多数休闲游戏,你可能不需要它。
10、BitPacker(序列化系统)
10.1 网络化自定义类、结构体和类型
PurrNet 会自动为你网络化结构体、类和接口,只要它们直接或间接包含可序列化的数据。
网络化自定义类和结构体非常有用,可以通过网络发送,例如使用基本的 RPC。
五种序列化方式
1. IPackedAuto(自动序列化 - 最简单)
让 PurrNet 自动处理序列化。适用于广播,因为 Network Behaviour 之外的数据可能难以通过网络序列化。
public struct PlayerData : IPackedAuto
{
public int playerId; // 自动序列化
public Vector3 position; // 自动序列化
public string playerName; // 自动序列化
}
2. IPackedSimple(简单自定义序列化)
允许你自己处理序列化。这是最简单的自定义序列化方式。
public struct PlayerData : IPackedSimple
{
public int playerId;
public Vector3 position;
public string playerName;
public void Serialize(BitPacker packer)
{
packer.Write(playerId); // 写入整数
packer.Write(position.x); // 写入浮点数
packer.Write(position.y);
packer.Write(position.z);
packer.Write(playerName); // 写入字符串
}
}
3. IPacked(完全自定义序列化)
也允许你自己处理序列化,但与之前的方法不同,它允许你将读取和写入数据分开,适用于更特殊的情况。
public struct PlayerData : IPacked
{
public int playerId;
public Vector3 position;
public string playerName;
public void Write(BitPacker packer) // 发送时调用
{
packer.Write(playerId);
packer.Write(position.x);
packer.Write(position.y);
packer.Write(position.z);
packer.Write(playerName);
}
public void Read(BitPacker packer) // 接收时调用
{
packer.Read(ref playerId);
float x = 0, y = 0, z = 0;
packer.Read(ref x);
packer.Read(ref y);
packer.Read(ref z);
position = new Vector3(x, y, z);
packer.Read(ref playerName);
}
}
4. 静态自定义类型(性能最高)
这是处理自定义数据读写的最性能的方式。PurrNet 序列化系统会自动找到你的静态类型。
// 为自定义的 PlayerInfo 类型添加序列化支持
public static class PlayerInfoSerializer
{
public static void Write(this BitPacker packer, PlayerInfo info)
{
packer.Write(info.id);
packer.Write(info.score);
packer.Write(info.isReady);
}
public static void Read(this BitPacker packer, ref PlayerInfo info)
{
packer.Read(ref info.id);
packer.Read(ref info.score);
packer.Read(ref info.isReady);
}
}
public struct PlayerInfo
{
public int id;
public int score;
public bool isReady;
}
5. 直接在 RPC 中打包数据(最高效)
使用 BitPackerPool 直接和最优地打包数据,然后通过 RPC 直接发送 BitPacker。这使用更少的内存,让你可以轻松动态打包自定义数据。
// 发送复杂数据
protected override void OnSpawned(bool asServer)
{
if (!asServer) // 客户端发送
{
using var writer = BitPackerPool.Get(); // 获取打包器
// 写入各种数据
writer.Write("玩家数据包"); // 字符串
writer.Write(100); // 整数
writer.Write(transform.position); // Vector3
writer.Write(true); // 布尔值
SendToServer(writer); // 发送给服务器
}
}
// 服务器接收
[ServerRpc(requireOwnership: false)]
private void SendToServer(BitPacker data)
{
using (data) // 确保释放资源
{
string title = "";
int score = 0;
Vector3 pos = Vector3.zero;
bool isReady = false;
data.Read(ref title); // 读取标题
data.Read(ref score); // 读取分数
data.Read(ref pos.x); // 读取位置
data.Read(ref pos.y);
data.Read(ref pos.z);
data.Read(ref isReady); // 读取状态
Debug.Log($"{title}: 分数={score}, 位置={pos}, 就绪={isReady}");
}
}
10.2 增量打包器 (Delta Packers)
增量打包器只写入旧值和新值之间的差异。与每次写入完整值相比,这减少了带宽使用。
PurrNet 会自动为你的类型生成增量序列化器。当你需要更多控制时,也可以定义自定义的增量逻辑。如果出现问题,我们会回退到一个安全的路径,其行为类似于"变更标记 + 完整值"。
何时使用
在以下情况下使用增量打包器:
- 你的值变化很小或很稀疏(例如,向量、旋转、小型结构体)
- 你希望在网络上传输的比特数少于完整值写入
- 你满意自动增量,或者想用自己的逻辑覆盖它
如果增量不适用(或发生错误),我们会自动写入一个小的"已变更"标记,并在需要时写入完整值。
API(每种类型)
通常你会调用的必要方法:
// 写入 oldValue -> newValue 的增量
// 如果有任何比特被写入则返回 true
bool DeltaPacker<T>.Write(BitPacker packer, T oldValue, T newValue)
// 读取增量并将其应用于 oldValue,产生结果
void DeltaPacker<T>.Read(BitPacker packer, T oldValue, ref T newValue)
注意:
- 对于内置或自动发现的静态序列化器,你不需要注册任何东西
- 如果没有可用的增量,会自动使用紧凑的回退方案
工作原理(高层次)
- 写入:决定是否有任何变化,只写入需要的内容
- 读取:消耗 Write 产生的所有内容,从旧值重建当前值
- 如果一个类型没有可用的增量序列化器,我们会写入单个"已变更"位,当为 true 时写入完整值(安全回退)
示例
只写入 Vector3 中已变更的字段:
// 客户端发送:只发送变化的部分
Vector3 oldPosition = player.lastPosition;
Vector3 newPosition = player.currentPosition;
DeltaPacker<Vector3>.Write(packer, oldPosition, newPosition);
// 服务器接收:从旧值重建新值
Vector3 oldValue = player.lastPosition;
Vector3 result = Vector3.zero;
player.currentPosition = DeltaPacker<Vector3>.Read(packer, oldValue, ref result);
自定义增量(高级,可选)
如果你想覆盖自动行为,可以定义自己的静态增量序列化器,类似于为普通打包器所做的那样。PurrNet 会自动发现在你静态类下定义的静态序列化器并自动使用它们。
你不需要手动注册这些 - 发现是自动的。
// 自定义 Vector3 的增量序列化器
public static class CustomVector3Delta
{
public static void Write(this BitPacker packer, Vector3 oldValue, Vector3 newValue)
{
// 只写入变化超过 0.01 的部分
bool xChanged = Mathf.Abs(oldValue.x - newValue.x) > 0.01f;
bool yChanged = Mathf.Abs(oldValue.y - newValue.y) > 0.01f;
bool zChanged = Mathf.Abs(oldValue.z - newValue.z) > 0.01f;
packer.Write(xChanged);
packer.Write(yChanged);
packer.Write(zChanged);
if (xChanged) packer.Write(newValue.x);
if (yChanged) packer.Write(newValue.y);
if (zChanged) packer.Write(newValue.z);
}
public static void Read(this BitPacker packer, Vector3 oldValue, ref Vector3 newValue)
{
bool xChanged = packer.ReadBool();
bool yChanged = packer.ReadBool();
bool zChanged = packer.ReadBool();
newValue.x = xChanged ? packer.ReadFloat() : oldValue.x;
newValue.y = yChanged ? packer.ReadFloat() : oldValue.y;
newValue.z = zChanged ? packer.ReadFloat() : oldValue.z;
}
}
通俗解释
什么是增量打包?
正常方式:每次更新位置,发送 (x=10, y=5, z=3) 全部三个值
增量方式:第一次发送 (10, 5, 3),第二次如果只 y 变成 6,只发送 (y=6),接收方知道其他值没变
为什么重要?
- 减少网络流量:只发变化的部分,不是每次都发所有数据
- 提高性能:网络传输更快,CPU 使用更少
- 适合实时游戏:像玩家位置这种频繁更新的数据特别适合
实际例子
想象你在玩在线游戏:
- 每帧玩家位置变化很小(可能只移动 0.1 个单位)
- 如果每帧都发送完整位置:
(x=100.1, y=50.2, z=30.3) - 使用增量后:第一次发完整值,之后只发
(dx=+0.1, dy=+0.1)
什么数据适合增量?
✅ 适合:
- 玩家位置(每次变化小)
- 玩家旋转
- 生命值(通常变化不大)
- 分数(逐步增加)
❌ 不适合:
- 随机生成的数据(每次完全不同)
- 切换状态(从"站立"到"跳跃"是完全改变)
- 聊天消息(每次都是新内容)
PurrNet 的增量系统很智能:如果不能有效增量,它会自动回退到发送完整值,所以你永远不需要担心数据丢失或错误!
11、代码剥离 (Code Stripping)
代码剥离允许自动从客户端构建中移除服务器代码。这为你的服务器-客户端游戏增加了一层安全性。PurrNet 会为你处理所有这些。
它会自动从除服务器构建外的所有构建中移除所有服务器特定的代码。你可以在 Project Settings -> Networking -> PurrNet -> Strip Server Code 中找到这个设置,只需切换布尔值即可。
你还可以使用 ServerOnly 属性来剥离并确保方法只在服务器上执行。
public class TestingClass
{
[ServerOnly(StripCodeModeOverride.ReplaceWithLogError)]
public void Testy()
{
Debug.Log("Testy called!");
}
}
通俗解释
什么是代码剥离?
想象一下你有一个餐厅:
- 厨师(服务器):需要知道所有食谱和制作方法
- 顾客(客户端):只需要知道菜单和如何点餐,不需要知道怎么做饭
代码剥离就是把"怎么做饭"的知识从顾客的菜单中移除,只留下他们需要知道的部分。
为什么重要?
- 安全性:防止黑客在客户端看到服务器逻辑
- 文件大小:客户端构建文件更小,下载更快
- 反作弊:关键游戏逻辑只在服务器上运行
工作原理
// 服务器代码示例
public class GameLogic : NetworkBehaviour
{
[ServerOnly] // 这个属性告诉PurrNet:只在服务器上运行
public void CalculateDamage()
{
// 复杂的伤害计算公式
// 客户端永远看不到这段代码
}
public void ShowDamageEffect()
{
// 显示伤害特效
// 客户端和服务器都能看到这段代码
}
}
构建结果对比
不剥离代码:
- 服务器构建:包含所有代码(厨师手册 + 顾客菜单)
- 客户端构建:包含所有代码(厨师手册 + 顾客菜单)← 不安全!
剥离代码后:
- 服务器构建:包含所有代码(厨师手册 + 顾客菜单)
- 客户端构建:只包含客户端代码(顾客菜单)← 安全!
使用场景
// 场景1:伤害计算(应该在服务器上进行)
[ServerOnly]
private void ApplyDamage(Player target, int damage)
{
target.health -= damage;
if (target.health <= 0) KillPlayer(target);
}
// 场景2:生成物品(应该在服务器上控制)
[ServerOnly]
public void SpawnPowerUp(Vector3 position)
{
var powerUp = Instantiate(powerUpPrefab, position, Quaternion.identity);
NetworkManager.Spawn(powerUp);
}
// 场景3:游戏状态更新
[ServerOnly]
public void StartNewRound()
{
currentRound++;
ResetPlayers();
SpawnEnemies();
}
ServerOnly 属性选项
// 选项1:完全移除(默认)
[ServerOnly]
public void ServerMethod() { /* 客户端完全看不到这段代码 */ }
// 选项2:替换为日志错误
[ServerOnly(StripCodeModeOverride.ReplaceWithLogError)]
public void ServerMethod()
{
// 在客户端上会变成:
// Debug.LogError("This method is server only!");
}
// 选项3:替换为抛异常
[ServerOnly(StripCodeModeOverride.ReplaceWithThrow)]
public void ServerMethod()
{
// 在客户端上会变成:
// throw new Exception("This method is server only!");
}
实际应用示例
public class GameManager : NetworkBehaviour
{
// 客户端需要知道游戏是否开始
public bool IsGameStarted { get; private set; }
// 但只有服务器能开始游戏
[ServerOnly]
public void StartGame()
{
if (!isServer) return; // 额外安全检查
IsGameStarted = true;
SpawnPlayers();
StartRoundTimer();
// 通知所有客户端游戏开始了
RpcGameStarted();
}
[ObserversRpc]
private void RpcGameStarted()
{
Debug.Log("游戏开始了!");
// 客户端显示UI等
}
// 计分逻辑应该在服务器上
[ServerOnly]
private void AddScore(Player player, int points)
{
player.score += points;
CheckForWinner();
}
}
重要提醒
- 测试要充分:确保剥离代码后客户端功能正常
- 小心使用:不要剥离客户端需要的代码
- 配合使用:
[ServerOnly]+if (!isServer) return双重保护 - 调试:使用
#if UNITY_SERVER条件编译进行调试
代码剥离是 PurrNet 提供的一个强大功能,能显著提高多人游戏的安全性和性能!
12、带宽分析器 (Bandwidth Profiler)
带宽分析器让你可以:
- 实时分析:通过实时流量图可视化发送和接收的数据
- 详细分解:检查每个RPC,查看其参数,并立即在层次结构中高亮显示源
GameObject - 保存和加载会话:通过将分析器数据保存到文件并在编辑器中加载回来调试构建
通过 Tools/PurrNet/Analysis/Bandwidth Profiler 访问分析器
带宽分析器窗口
分析器还会尽可能保留对发送者/接收者的引用,让你确切知道是哪些组件在发送数据。
它还允许你在运行时将数据保存到文件,以便在游戏会话后进行检视和分析。
五、一键式组件
插件式组件允许您简单地将组件添加到对象中,以便自动处理一些网络操作。这种组件的典型示例是网络变换器 ,它允许您自动将对象的位置、旋转和缩放同步到网络上。或者还有网络动画师 ,它允许您自动同步动画。
1、网络变换组件 (Network Transform)
这个组件能自动同步所附加游戏对象的位置、旋转、缩放和父级变换。
设置选项:
Sync Settings同步设置: 允许你轻松调整需要同步的内容
Interpolate Settings插值设置: 允许你修改哪些值需要进行插值(平滑处理)
Owner Auth所有者授权: 如果为false,服务器将拥有控制权
Sync Parent同步父级: 如果变换的父级发生变化,是否也同步
Send interval ticks发送间隔(tick): 数据发送的频率
Tolerances容差值: 允许你调整值变化多少才触发同步。避免同步微小变化造成不必要的网络流量。
2、网络动画器 (Network Animator)
这个组件允许你自动同步动画器中的任何参数值。该组件需要与动画器放在同一个游戏对象上,否则无法工作。
它会显示动画器中的所有参数值,你可以通过开关选择要同步哪些参数,不同步哪些参数。
注意: 这个组件不会自动同步"触发器"。触发器需要通过NetworkAnimator组件来调用,类似于在普通动画器上的调用方式。
这是调用触发器的方法:
private NetworkAnimator _netAnimator;
private void DoPunch() {
_netAnimator.SetTrigger("Punch");
}
网络动画器还允许你直接通过它来控制动画,所以你不需要同时引用普通动画器和网络动画器。你可以轻松处理各种参数:
private NetworkAnimator _netAnimator;
private Rigidbody _playerRigidbody;
private void DoPunch() {
_netAnimator.SetFloat("Speed", _playerRigidbody.velocity.magnitude);
_netAnimator.SetBool("IsMoving", _playerRigidbody.velocity.magnitude > 0.1f);
//动画器的所有操作都可以这样处理。上面只是两个例子。
}
3、网络所有权切换组件 (Network Ownership Toggle)
这个组件允许你根据所有权来启用/激活组件或游戏对象。
注意每个条目最右边都有一个开关,这个开关表示如果我是所有者则激活,否则停用。

在上面的例子中,PlayerMovement只会为所有者启用,为其他人禁用;而Text游戏对象会为所有者禁用,为其他人启用。
这在快速设置网络预制体时非常方便,无需编写太多代码。
4、网络反射组件 (Network Reflection)
网络反射组件会以另一个组件作为输入,并显示其中的所有值,让你选择哪些应该自动通过网络同步。这使得将你的脚本从单机版转换为多人版变得非常简单。这可以用于任何组件,甚至是Unity的默认组件。
它还包含"所有者授权"设置,用于决定谁负责这个同步过程,是所有者还是服务器。

5、网络状态机 (Network State Machine)
PurrNet的自动网络化状态机使得同步状态机变得非常容易,即使在需要自定义状态数据的情况下也是如此。
状态机还有一个自定义编辑器,便于调试和运行时操作活动状态。它还会尝试序列化自定义节点数据。
默认情况下,状态机是服务器授权的,但你可以使用检查器中的布尔值来更改,如果你希望它是所有者授权的话。主要区别在于谁被允许更改状态机中的状态。“控制器”(无论是服务器还是所有者)总是会在本地处理状态更改,使其快速响应。
状态机在保持游戏状态同步方面非常有用,我们个人用它来处理游戏状态,可能看起来像这样:
- 倒计时状态
- 生成敌人状态
- 生成Boss状态
- 回合结束状态
- 商店状态
它们也可以用于其他情况,比如使用所有者授权时的玩家状态。
你可以根据需要自由切换状态,也可以方便地按顺序切换状态。
场景设置
场景设置非常简单!你只需要将状态机添加到你的场景中。我建议为这些创建一个新的游戏对象,以保持结构清晰。
对于你创建的每个状态节点,你也可以将其添加到你的场景中。我通常将其作为状态机游戏对象的子对象。

状态节点
状态节点就是你熟悉的状态。有两种类型的状态节点。一种是需要数据的,另一种是不需要数据的。
首先,要有一个状态节点,你需要一个新脚本,该脚本继承自StateNode或StateNode<T>,具体取决于状态是否需要数据。
using PurrNet.StateMachine;
public class TestState : StateNode
{
}
StateNode也是一个网络行为,所以你可以像处理任何其他网络化脚本一样使用它。主要区别在于你可以重写的一些虚方法,以及访问状态机以更改状态的便利性。
public override void Enter(bool asServer)
{
//当状态进入时运行
}
public override void Exit(bool asServer)
{
//当状态退出时运行
}
public override void StateUpdate(bool asServer)
{
//当状态激活时每帧运行
}
切换状态
使用网络状态机切换状态非常容易。唯一需要的是对状态机的引用。状态节点通过简单的输入machine自动拥有该引用。
有三种更改状态的方式:Next(下一个)、Previous(上一个)或SetState(设置特定状态)。
public StateNode specificState;
private void StateChangeShowcase()
{
//转到状态机列表中的下一个状态
machine.Next();
//转到状态机列表中的上一个状态
machine.Previous();
//转到状态机列表中的特定状态
machine.SetState(specificState);
}
带数据的状态节点
使用需要数据的状态节点也很容易。你使用它的方式与常规状态节点非常相似,只是你使用所需数据的类型将其设为泛型:
public class TestState : StateNode<int>
{
public override void Enter(int data, bool asServer)
{
Debug.Log($"我接收到的数据: {data}");
}
}
请注意,如果状态切换时没有提供数据,它将运行没有数据类型的普通Enter方法。这样,如果状态可能被错误地进入,你总是可以将其用作安全措施。
为了在状态切换时发送数据,你只需在更改状态时提供数据:
//在提供数据的情况下转到状态机列表中的下一个状态
machine.Next(5);
//在提供数据的情况下转到指定的状态
machine.SetState(specificState, 5);
高级状态转换
PurrNet状态机通过CanEnter和CanExit方法支持高级转换逻辑,允许条件状态更改。
实现条件转换
每个StateNode都可以重写这些方法以定义自定义逻辑:
- 非泛型状态:重写
CanEnter()和CanExit()来实现无需额外数据的逻辑 - 泛型状态:重写
CanEnter(T data)和CanExit()来实现考虑外部数据的逻辑
通过使用这些方法,你可以确保只有在满足某些条件时状态机才会转换,从而增强游戏逻辑的健壮性。
动态添加和删除状态
网络状态机还支持动态修改状态。这也会自动同步,这意味着你可以实例化一个新状态并将其添加到现有状态机中,或者根据需要删除现有状态。
动态添加和删除状态与处理列表非常相似:
//将状态添加到状态列表的末尾
machine.AddState(_stateToAdd);
//删除给定引用的状态
machine.RemoveState(_stateToAdd);
//在给定索引处添加/插入状态
machine.InsertState(_stateToAdd, _indexTest);
//从给定索引处删除状态
machine.RemoveStateAt(_indexTest);
专栏推荐
完结
好了,我是向宇,博客地址:https://xiangyu.blog.csdn.net,如果学习过程中遇到任何问题,也欢迎你评论私信找我。
赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注,你的每一次支持都是我不断创作的最大动力。当然如果你发现了文章中存在错误或者有更好的解决方法,也欢迎评论私信告诉我哦!
这里是一个专注于游戏开发的社区,我们致力于为广大游戏爱好者提供一个良好的学习和交流平台。我们的专区包含了各大流行引擎的技术博文,涵盖了从入门到进阶的各个阶段,无论你是初学者还是资深开发者,都能在这里找到适合自己的内容。除此之外,我们还会不定期举办游戏开发相关的活动,让大家更好地交流互动。加入我们,一起探索游戏开发的奥秘吧!
更多推荐


所有评论(0)