HelloNetcode Relay Server 样例:基于 Unity Relay 服务的 Netcode 联机接入实现
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
本文以 HelloNetcode 示例集中的 Relay Server 样例为对象,完整解析 Netcode 项目如何集成 Unity Relay 中继服务实现 NAT 穿透联机:从 Frontend 场景的 UI 交互、Host 与 Client 两条异步状态机流程,到RelayServerData的构造与驱动层(Driver)的注册机制,最终帮助读者掌握在 Unity Netcode(基于 Entities/NetStream)项目中接入 Relay 的完整链路。
样例定位与前置要求
Relay Server 样例位于 RelayServer.md 所描述的目录中,是 HelloNetcode 基础样例集里专门用于演示"中继服务器集成"的场景。Relay 是 Unity 提供的托管服务,客户端通过中继节点与主机通信,从而绕过 NAT 直接连接无法达成的网络环境。
运行该样例的前提(对应原文档Requirements):
- 已为项目完成 Relay 服务的接入配置,即按照 Unity 官方 Relay 文档的 "Get started with Relay" 流程创建 Unity 服务、开通 Relay 产品并获得可用的服务配置(涉及 Unity Services 的初始化与匿名登录能力)。
- 项目中包含 Unity Netcode(
Unity.NetCode)、Unity Transport(Unity.Networking.Transport及其 Relay 扩展)、Unity Relay 服务包(Unity.Services.Relay)、Authentication 服务包与 Services 核心包。样例源码的using引用可以直接印证这些依赖:HostServer.cs 顶部引用了Unity.Services.Relay、Unity.Services.Authentication、Unity.Services.Core与Unity.Networking.Transport.Relay命名空间。
样例的目录结构如下:
| 文件 | 职责 |
|---|---|
| NetcodeSetup/RelayFrontend.cs | Frontend 场景 HUD,负责按钮行为、Host/Client 设置流程编排与状态显示 |
| NetcodeSetup/RelayDriverConstructor.cs | 自定义驱动构造器,按 Relay 配置注册 UDP/WebSocket/IPC 驱动 |
| NetcodeSetup/RelayHUD.cs | 对局内 HUD,定义JoinCode组件并显示 join code |
| HostServer.cs | ECS 系统,负责联系 Relay 服务、分配会话并生成 join code |
| ConnectingPlayer.cs | ECS 系统,负责用 join code 加入 Relay 会话 |
| EnableRelayServer.cs | 启用组件,控制样例系统是否运行 |
| RelayUtilities.cs | 按连接类型(dtls/wss)筛选 Relay 端点的工具方法 |
| RelayFrontend.unity、RelayHUD.unity | 入口场景与对局场景 |
UI 交互流程:Relay 开关与 Join Code 输入框
该样例通过 Frontend 场景(RelayFrontend.unity)中的Relay Server 复选框与按钮联动。按照 RelayServer.md 的描述:
- 勾选Relay Support后点击Start Client & Server,会启动一个启用 Relay 的主机;随后客户端通过 Relay 服务连入该主机。
- Join Existing Game按钮用于把客户端连入一个已存在的 Relay 会话(即"加入现有游戏")。
- Relay Server 复选框旁会显示状态文本;启动与初始化 Relay 服务过程中若出错,界面显示失败信息,具体细节输出到控制台日志。
- 切换 Relay 开关时,地址/端口输入区会被替换为单个输入框,用于填写 join code——这是经 Relay 服务连入主机会话所需的全部信息。
这些行为在 RelayFrontend.cs 的OnRelayEnable方法中实现:开启时隐藏端口输入(Port.gameObject.SetActive(false))、启用"本地连接是否也走 Relay"的切换、清空输入框并把占位文本改为 "Enter Join Code...";关闭时恢复地址输入(默认回填127.0.0.1)。此外,Update()中有一段交互约束逻辑:Relay 开启且 join code 输入框非空时,会禁用 Client & Server 按钮,因为此时用户意图是"加入会话"而非"自建会话"(RelayFrontend.cs)。
RelayFrontend用ConnectionState状态机(SetupHost → SetupClient → JoinGame/JoinLocalGame → Unknown)在每帧Update()中推进流程:当 Host 侧系统返回有效的RelayServerData.Endpoint、或 Client 侧RelayClientData.Endpoint有效时,才进入下一步启动世界(World)与驱动连接(RelayFrontend.cs)。这正是原文档所说"状态消息显示在复选框旁、错误细节进控制台"的实现方式:各WaitForXxx方法在 Task 失败时Debug.LogError/LogException,并经由UIBehaviour.HostConnectionStatus/ClientConnectionStatus属性把短状态写回 UI 文本。
Host 侧流程:HostServer 的异步状态机
HostServer.cs 的系统注释完整列出了主机接入 Relay 的五个步骤:
- 初始化服务(
UnityServices.InitializeAsync()) - 匿名登录(
AuthenticationService.Instance.SignInAnonymouslyAsync()) - 分配会话(
RelayService.Instance.CreateAllocationAsync(RelayMaxConnections),样例中RelayMaxConnections = 5,即最多 5 个外部 peer 连接、连同主机共 6 名玩家) - 获取 join code(
RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId)) - 获取 Relay 服务器信息(IP、端口等,构造
RelayServerData)
系统以HostStatus标志枚举驱动状态机,OnUpdate()每帧轮询各Task的完成状态,把上述 5 步串起来(HostServer.cs):
// HostServer.cs 中的状态推进(节选) case HostStatus.InitializeServices: m_InitializeTask = UnityServices.InitializeAsync(); m_HostStatus = HostStatus.Initializing; break; case HostStatus.SigningIn: m_HostStatus = WaitForSignIn(m_SignInTask, out m_AllocationTask); break; case HostStatus.Allocating: m_HostStatus = WaitForAllocations(m_AllocationTask, out m_JoinCodeTask); break; case HostStatus.GetRelayData: m_HostStatus = BindToHost(m_AllocationTask, out RelayServerData); break;其中几个关键实现细节值得注意:
- 系统激活条件:
HostServer标注[DisableAutoCreation],并在OnCreate()中RequireForUpdate<EnableRelayServer>()(HostServer.cs)。EnableRelayServer是一个空的IComponentData结构体(EnableRelayServer.cs)——HelloNetcode 的惯例是:只有场景(或运行时)里存在某个样例的"启用组件"实体,该样例的系统才会运行,避免所有样例系统同时被驱动。RelayFrontend在HostServer()与JoinAsClient()中都会手动CreateEntity加上该组件(RelayFrontend.cs)。 - 连接类型选择:非 WebGL 平台使用
dtls,WebGL 使用wss(HostServer.cs)。源码注释明确说明connectionType也支持udp,但不推荐。 RelayServerData的 nonce 语义:主机在HostRelayData中把自己的ConnectionData传两次(自身既是主机又是"连接方"),客户端则传"自己的 ConnectionData + 主机的 HostConnectionData"(见下文)。端点选择由 RelayUtilities.GetEndpointForConnectionType 从allocation.ServerEndpoints列表中按ConnectionType过滤得到。
// HostServer.HostRelayData:构造主机侧 RelayServerData(节选) var endpoint = RelayUtilities.GetEndpointForConnectionType(allocation.ServerEndpoints, connectionType); var isWebSocket = connectionType == "wss" || connectionType == "ws"; var relayServerData = new RelayServerData(endpoint.Host, (ushort)endpoint.Port, allocation.AllocationIdBytes, allocation.ConnectionData, allocation.ConnectionData, allocation.Key, endpoint.Secure, isWebSocket);(HostServer.cs)
Client 侧流程:ConnectingPlayer 用 Join Code 加入会话
ConnectingPlayer.cs 是客户端侧的对应系统,同样以RequireForUpdate<EnableRelayServer>()门控,通过ClientStatus状态机推进:WaitForInit → WaitForSignIn → WaitForJoin → Ready(或FailedToConnect)。核心调用是:
// ConnectingPlayer.JoinUsingJoinCode:把 join code 发给 Relay 服务 joinTask = RelayService.Instance.JoinAllocationAsync(hostServerJoinCode);(ConnectingPlayer.cs)
join code 有两个来源,对应样例 UI 的两种玩法:
- 本机同进程自测:
RelayFrontend在SetupClient()之后调用m_HostClientSystem.GetJoinCodeFromHost(),系统直接读取本机HostServer系统已生成的JoinCode(ConnectingPlayer.cs)。 - 模拟远端玩家:用户在 Frontend 输入框里填写 host 展示出来的 join code,
JoinAsClient()将输入值传入JoinUsingCode,先做UnityServices.InitializeAsync()与匿名登录,再发起JoinAllocationAsync(RelayFrontend.cs)。
加入成功后,PlayerRelayData把JoinAllocation转换为客户端视角的RelayServerData:注意此处ConnectionData与HostConnectionData的顺序与主机侧不同——客户端携带自己的连接凭据和主机的连接凭据:
var relayServerData = new RelayServerData(endpoint.Host, (ushort)endpoint.Port, allocation.AllocationIdBytes, allocation.ConnectionData, allocation.HostConnectionData, allocation.Key, endpoint.Secure, connectionType == "wss");(ConnectingPlayer.cs)
驱动层:RelayDriverConstructor 如何按配置选择传输
拿到两侧RelayServerData后,真正的网络驱动由自定义的INetworkStreamDriverConstructor实现 RelayDriverConstructor.cs 创建。其 XML 注释给出了清晰的决策表:
Mode | Relay Settings Client/Server | Valid -> 用 relay 连接本地服务器 | Invalid -> 用 IPC 连接本地服务器 Client | 总是走 relay,期望数据有效CreateClientDriver:若请求的是 ClientAndServer 模式且客户端 Relay 数据无效(Endpoint.IsValid为假),回退到RegisterClientIpcDriver(本机进程内 IPC);否则对RelayServerData执行settings.WithRelayParameters(...)后注册 UDP 驱动;WebGL 平台则注册 WebSocket 驱动(RelayDriverConstructor.cs)。CreateServerDriver:主机同时注册两个驱动——第一个是 IPC(供本机内部客户端使用),第二个在相同端口上以 Relay 参数监听,承接外部经中继进来的连接(RelayDriverConstructor.cs)。
RelayFrontend.SetupRelayHostedServerAndConnect()展示了这套构造器如何被临时挂载:先保存旧的NetworkStreamReceiveSystem.DriverConstructor,替换为new RelayDriverConstructor(relayServerData, relayClientData),调用ClientServerBootstrap.CreateServerWorld("ServerWorld")/CreateClientWorld("ClientWorld")手动创建服务器与客户端 World,再恢复原构造器(RelayFrontend.cs)。随后:
- 服务端驱动执行
Listen(NetworkEndpoint.AnyIpv4); - 客户端驱动若持有有效
relayClientData则Connect(client.EntityManager, relayClientData.Value.Endpoint)走 Relay,否则回退连接 IPC 端点ipcLocalEndPoint(RelayFrontend.cs)。 - 若 Host 与 Client 位于不同构建/机器(纯客户端场景),则走
ConnectToRelayServer():创建客户端 World 后写入NetworkStreamRequestConnect实体,Endpoint指向 Relay 端点。注释特别说明:连接不会立即绑定,Request 结构体会让传输层持续轮询直到连接建立(RelayFrontend.cs)。
Join Code 的展示:JoinCode 组件与 RelayHUD
原文档提到"join code 会显示在主机对局的左上角"。这一功能由 RelayHUD.cs 实现:
public struct JoinCode : IComponentData { public FixedString64Bytes Value; } public class RelayHUD : MonoBehaviour { public void Awake() { var world = World.All[0]; var joinQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<JoinCode>()); if (joinQuery.HasSingleton<JoinCode>()) { var joinCode = joinQuery.GetSingleton<JoinCode>().Value; JoinCodeLabel.text = $"Join code: {joinCode}"; } } }数据链路上,RelayFrontend.SetupRelayHostedServerAndConnect()在服务端 World 中创建携带JoinCode(FixedString64Bytes)的单例实体(RelayFrontend.cs),并SceneManager.LoadScene("RelayHUD", LoadSceneMode.Additive)附加加载 HUD 场景;RelayHUD.Awake()查询该单例组件并写入 UI 文本,完成"主机生成 join code → 写入实体 → HUD 读取展示"的闭环。
设计决策:两种 Relay 连接测试模式
对应原文档Design decisions一节,样例刻意提供两种可切换的验证方式(Frontend 菜单中的UseRelayForLocalConnectionToggle 用于选择,见 RelayFrontend.cs):
- 自托管 + Relay 只用于入站:主机经 Relay 对外提供连接,但同进程内的客户端走 IPC 直连本地服务器。这是最常见的"我既是 host 又当玩家"场景,能验证主机双驱动注册(IPC + Relay)的正确性。
- 模拟远端客户端:同进程客户端也强制走 Relay 链路连入本地主机,端到端验证"客户端拿 join code → JoinAllocation → 经中继连接主机"的完整路径。
RelayFrontend的ConnectionState.SetupClient分支根据m_UseRelayForLocalClient决定是否调用SetupClient()并等待客户端 Relay 数据就绪,再进入JoinLocalGame分支启动双 World(RelayFrontend.cs);模式 2 的纯客户端路径则由JoinGame分支中的ConnectToRelayServer()完成。
Web 构建约束
原文档Web build constraints指出:Web 构建下,未勾选 Relay 前Start Client & Server 按钮保持禁用——因为 WebGL 无法以 UDP 直接对外监听,只能经 Relay(WebSocket)联机。源码中两处实现相互印证:
- RelayFrontend.cs:
Start()中#if UNITY_WEBGL时ClientServerButton.interactable = false; OnRelayEnable中:勾选 Relay 时ClientServerButton.interactable = true;取消勾选时 WebGL 下恢复禁用(RelayFrontend.cs)。
传输层的对应关系是:RelayDriverConstructor与HostServer/ConnectingPlayer都通过#if !UNITY_WEBGL区分dtls/UDP 与wss/WebSocket 两条路径;RelayDriverConstructor的注释还提到,在 Editor 中 WebGL 的客户端始终优先使用 WebSocket,以尽量贴近打包后的玩家行为。另外,RelayFrontend基类在UNITY_SERVER(专用服务器构建)下退化为普通MonoBehaviour,UI 相关逻辑被#if !UNITY_SERVER条件编译隔离,说明该 Frontend 面向带客户端的构建,专用服务器只复用其核心流程。
Play Mode 下的延迟现象
原文档附有一条值得保留的使用提示:在 Play Mode 中启用 Relay 后,对局场景的加载会比不启用时更慢。该延迟来源于到 Relay 服务的往返时间(RTT)——HostServer与ConnectingPlayer两个系统分别需要完成"服务初始化 → 匿名登录 → 分配/加入会话 → 获取 join code → 拉取 Relay 端点"的多轮异步往返,且RelayFrontend的Update()状态机必须等到RelayServerData.Endpoint/RelayClientData.Endpoint有效后才会创建 World 并启动驱动连接,因此这部分网络耗时直接体现在场景启动前的等待上。
小结
HelloNetcode 的 Relay Server 样例用一套紧凑而完整的代码展示了 Netcode 接入 Unity Relay 的标准链路:HostServer/ConnectingPlayer两个门控 ECS 系统分别封装主机分配与客户端加入的异步状态机;RelayServerData(含双份ConnectionData的 nonce 约定、dtls/wss 端点选择)在RelayDriverConstructor中驱动 UDP/WebSocket/IPC 三类传输的注册;EnableRelayServer组件与JoinCode单例实体则承担了样例开关与 join code 传递的职责。理解这条"服务层(Unity Relay SDK)→ 数据层(RelayServerData)→ 传输层(DriverConstructor)→ 表现层(Frontend/HUD)"的四层结构后,即可将其作为自研多人联机项目的 Relay 接入参考实现。
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考