Unreal Engine 5 联机网络架构技术手册
Unreal Engine 5 联机网络架构技术手册
- [Unreal Engine 5 联机网络架构技术手册](#Unreal Engine 5 联机网络架构技术手册)
- 前言
-
- [一、网络传输层基础:从 Socket 到游戏协议](#一、网络传输层基础:从 Socket 到游戏协议)
-
- [1.1 俩台电脑如何建立连接](#1.1 俩台电脑如何建立连接)
-
- [1.1.1 从寄信开始说起](#1.1.1 从寄信开始说起)
- [1.1.2 什么是Socket?(重点)](#1.1.2 什么是Socket?(重点))
- [1.1.3 客户端 vs 服务器](#1.1.3 客户端 vs 服务器)
- [1.2 TCP](#1.2 TCP)
-
- [1.2.1 TCP的核心特点](#1.2.1 TCP的核心特点)
- [1.2.2 三次握手](#1.2.2 三次握手)
-
- [1.2.2.1 TCP报文中的关键字段](#1.2.2.1 TCP报文中的关键字段)
- [1.2.2.3 三次握手中的状态变化](#1.2.2.3 三次握手中的状态变化)
- [1.2.2.4 与C++编程的对应关系](#1.2.2.4 与C++编程的对应关系)
- [1.2.3 可靠性是怎么实现的?](#1.2.3 可靠性是怎么实现的?)
- [1.2.4 TCP的缺点:队头"阻塞"](#1.2.4 TCP的缺点:队头“阻塞”)
- [1.2.5 亲手写一个TCP程序(控制台版)](#1.2.5 亲手写一个TCP程序(控制台版))
- [1.3 UDP](#1.3 UDP)
-
- [1.3.1 UDP的核心特点](#1.3.1 UDP的核心特点)
- [1.3.2 UDP的包结构(为什么它快)](#1.3.2 UDP的包结构(为什么它快))
- [1.3.3 亲手写一个UDP程序](#1.3.3 亲手写一个UDP程序)
- [1.3.4 UDP的丢包和乱序问题](#1.3.4 UDP的丢包和乱序问题)
- [1.4 TCP vs UDP:游戏到底用哪个?](#1.4 TCP vs UDP:游戏到底用哪个?)
-
- [1.4.2 详细对比表](#1.4.2 详细对比表)
- [1.4.3 游戏里分别用在什么地方](#1.4.3 游戏里分别用在什么地方)
- [1.5 UE的流程](#1.5 UE的流程)
- 二、UE5的联机流程
-
- [2.1 服务器与客户端](#2.1 服务器与客户端)
-
- [2.1.1 一个生活中的例子](#2.1.1 一个生活中的例子)
- [2.1.2 "权威"这个概念(非常重要!)](#2.1.2 “权威”这个概念(非常重要!))
- [2.2 UE5的三种运行模式](#2.2 UE5的三种运行模式)
-
- [2.2.1 模式1:Standalone(独立模式&单机模式)](#2.2.1 模式1:Standalone(独立模式&单机模式))
- [2.2.2 模式2:Dedicated Server(专用服务器)](#2.2.2 模式2:Dedicated Server(专用服务器))
- [2.2.3 模式3:Listen Server(监听服务器)](#2.2.3 模式3:Listen Server(监听服务器))
- [2.2.4 三种模式对比表](#2.2.4 三种模式对比表)
- [2.3 如何判断当前模式](#2.3 如何判断当前模式)
- [2.4 NetRole:同一个Actor的不同"身份"(重点!)](#2.4 NetRole:同一个Actor的不同“身份”(重点!))
-
- [2.4.1 为什么需要 NetRole?](#2.4.1 为什么需要 NetRole?)
- [2.4.2 三种 NetRole](#2.4.2 三种 NetRole)
- [2.4.3 一个具体例子](#2.4.3 一个具体例子)
- [2.4.4 代码里怎么判断 NetRole?](#2.4.4 代码里怎么判断 NetRole?)
- [2.5 实战:让你写的函数只在正确的地方执行](#2.5 实战:让你写的函数只在正确的地方执行)
-
- [2.5.1 判断表(收藏这个表)](#2.5.1 判断表(收藏这个表))
- [三、同步架构流派:状态同步 vs 帧同步](#三、同步架构流派:状态同步 vs 帧同步)
-
- [3.1 什么是"同步"?------联机游戏最核心的问题](#3.1 什么是“同步”?——联机游戏最核心的问题)
-
- [3.1.1 问题场景](#3.1.1 问题场景)
- [3.1.2 两种方案](#3.1.2 两种方案)
- [3.2 状态同步(State Synchronization)------ UE5的方案](#3.2 状态同步(State Synchronization)—— UE5的方案)
-
- [3.2.1 核心思路](#3.2.1 核心思路)
- [3.2.2 状态同步的优点](#3.2.2 状态同步的优点)
- [3.2.3 状态同步的缺点](#3.2.3 状态同步的缺点)
- [3.3 帧同步(Lockstep / Frame Synchronization)------ 另一种方案](#3.3 帧同步(Lockstep / Frame Synchronization)—— 另一种方案)
-
- [3.3.1 核心思路](#3.3.1 核心思路)
- [3.3.2 帧同步的关键:确定性](#3.3.2 帧同步的关键:确定性)
- [3.3.3 帧同步的优点](#3.3.3 帧同步的优点)
- [3.3.4 帧同步的缺点](#3.3.4 帧同步的缺点)
- [3.3. 帧同步的适用场景](#3.3. 帧同步的适用场景)
- [3.4 详细对比表](#3.4 详细对比表)
- [3.5 为什么UE5选择状态同步?](#3.5 为什么UE5选择状态同步?)
-
- [3.5.1 技术原因](#3.5.1 技术原因)
- [3.5.2 商业原因](#3.5.2 商业原因)
- [3.5.3 UE5官方的选择](#3.5.3 UE5官方的选择)
- [3.6 在UE5里怎么写状态同步的代码](#3.6 在UE5里怎么写状态同步的代码)
-
- [3.6.1 最小示例](#3.6.1 最小示例)
- [3.6.2 位置同步(角色移动)](#3.6.2 位置同步(角色移动))
- [3.6.3 状态同步的优化技巧](#3.6.3 状态同步的优化技巧)
- 四、核心同步机制:属性复制(Replication)
-
- [4.1 什么是属性复制?](#4.1 什么是属性复制?)
-
- [4.1.1 一个没有属性复制的世界](#4.1.1 一个没有属性复制的世界)
- [4.1.2 有属性复制的世界](#4.1.2 有属性复制的世界)
- [4.1.3 属性复制的流程(通俗版)](#4.1.3 属性复制的流程(通俗版))
- [4.2 怎么用属性复制?(三步走)](#4.2 怎么用属性复制?(三步走))
-
- [步骤1:在变量声明上加 `UPROPERTY(Replicated)`](#步骤1:在变量声明上加
UPROPERTY(Replicated)) - [步骤2:在 `GetLifetimeReplicatedProps` 里注册](#步骤2:在
GetLifetimeReplicatedProps里注册) - 步骤3:**只在服务器上修改这个变量的值**
- [步骤1:在变量声明上加 `UPROPERTY(Replicated)`](#步骤1:在变量声明上加
- [4.3 当属性变化时执行代码(OnRep)](#4.3 当属性变化时执行代码(OnRep))
-
- [4.3.1 为什么需要 OnRep?](#4.3.1 为什么需要 OnRep?)
- [4.3.2 OnRep 的用法](#4.3.2 OnRep 的用法)
- [4.3.3 OnRep 的触发时机](#4.3.3 OnRep 的触发时机)
- [4.3.4 强制重复值也触发 OnRep](#4.3.4 强制重复值也触发 OnRep)
- [4.4 复制条件(谁能看到这个属性?)](#4.4 复制条件(谁能看到这个属性?))
-
- [4.4.1 问题场景](#4.4.1 问题场景)
- [4.4.2 常用的复制条件](#4.4.2 常用的复制条件)
- [4.4.3 代码示例](#4.4.3 代码示例)
- [4.4.4 条件复制的工作原理图](#4.4.4 条件复制的工作原理图)
- [4.5 属性复制的底层原理](#4.5 属性复制的底层原理)
-
- [4.5.1 复制的Shadow Buffer](#4.5.1 复制的Shadow Buffer)
- [4.5.2 增量同步](#4.5.2 增量同步)
- [4.5.3 新玩家加入](#4.5.3 新玩家加入)
- [4.6 属性复制的性能优化](#4.6 属性复制的性能优化)
-
- [4.6.1 常见性能问题](#4.6.1 常见性能问题)
- [4.6.2 优化技巧](#4.6.2 优化技巧)
- [4.7 完整示例:一个带属性复制的角色](#4.7 完整示例:一个带属性复制的角色)
- 五、核心同步机制:RPC
-
- [5.1 什么是RPC(远端调用)?------ 像调用本地函数一样调用远程函数](#5.1 什么是RPC(远端调用)?—— 像调用本地函数一样调用远程函数)
-
- [5.1.1 问题场景](#5.1.1 问题场景)
- [5.1.2 RPC的通俗理解](#5.1.2 RPC的通俗理解)
- [5.1.3 RPC vs 属性复制:什么时候用哪个?](#5.1.3 RPC vs 属性复制:什么时候用哪个?)
- [5.2 三种RPC类型](#5.2 三种RPC类型)
-
- [5.2.1 Server RPC(客户端 → 服务器)](#5.2.1 Server RPC(客户端 → 服务器))
- [5.2.2 Client RPC(服务器 → 特定客户端)](#5.2.2 Client RPC(服务器 → 特定客户端))
- [5.2.3 NetMulticast RPC(服务器 → 所有客户端)](#5.2.3 NetMulticast RPC(服务器 → 所有客户端))
- [5.3 Reliable vs Unreliable](#5.3 Reliable vs Unreliable)
-
- [5.3.1 区别](#5.3.1 区别)
- [5.3.2 什么时候用什么?](#5.3.2 什么时候用什么?)
- [5.3.3 Reliable的陷阱:通道阻塞](#5.3.3 Reliable的陷阱:通道阻塞)
- [5.4 RPC的验证(防止作弊)](#5.4 RPC的验证(防止作弊))
-
- [5.4.1 问题](#5.4.1 问题)
- [5.4.2 WithValidation](#5.4.2 WithValidation)
- [5.4.3 常见验证规则](#5.4.3 常见验证规则)
- [5.5 RPC的参数类型限制](#5.5 RPC的参数类型限制)
- [5.6 RPC命名规则](#5.6 RPC命名规则)
- [5.7 实际项目中的RPC设计模式](#5.7 实际项目中的RPC设计模式)
- [5.8 RPC与属性复制的配合](#5.8 RPC与属性复制的配合)
- [六、深入底层:UActorChannel 与数据流转](#六、深入底层:UActorChannel 与数据流转)
-
- [6.1 为什么需要了解底层?](#6.1 为什么需要了解底层?)
- [6.2 完整的数据流转过程](#6.2 完整的数据流转过程)
-
- [6.2.1 发送端(服务器)](#6.2.1 发送端(服务器))
- [6.2.2 接收端(客户端)](#6.2.2 接收端(客户端))
- [6.3 UActorChannel:每个Actor一条专用通道](#6.3 UActorChannel:每个Actor一条专用通道)
- [6.4 Bunch:数据包的逻辑单元](#6.4 Bunch:数据包的逻辑单元)
- [6.5 NetDriver的复制循环](#6.5 NetDriver的复制循环)
- [七、相关性裁剪:Relevancy 系统详解](#七、相关性裁剪:Relevancy 系统详解)
-
- [7.1 为什么需要Relevancy?](#7.1 为什么需要Relevancy?)
- [7.2 默认的相关性规则](#7.2 默认的相关性规则)
- [7.3 配置相关性](#7.3 配置相关性)
-
- [7.3.1 距离裁剪](#7.3.1 距离裁剪)
- [7.3.2 全局设置](#7.3.2 全局设置)
- [7.3.3 特定标记](#7.3.3 特定标记)
- [7.4 自定义相关性过滤](#7.4 自定义相关性过滤)
-
- [7.4.1 重写 IsNetRelevantFor](#7.4.1 重写 IsNetRelevantFor)
- [7.5 超出范围后的恢复](#7.5 超出范围后的恢复)
- 八、玩家生命周期:登录、登出与断线重连
-
- [8.1 玩家加入的完整流程](#8.1 玩家加入的完整流程)
- [8.2 PreLogin与PostLogin的实现](#8.2 PreLogin与PostLogin的实现)
- [8.3 登出与清理](#8.3 登出与清理)
- [8.4 断线重连](#8.4 断线重连)
-
- [8.4.1 服务端:保存玩家状态](#8.4.1 服务端:保存玩家状态)
- [8.4.2 客户端:主动重连](#8.4.2 客户端:主动重连)
- [九、会话管理:Session 与 OnlineSubsystem](#九、会话管理:Session 与 OnlineSubsystem)
-
- [9.1 什么是 Session?](#9.1 什么是 Session?)
- [9.2 OnlineSubsystem 是什么?](#9.2 OnlineSubsystem 是什么?)
- [9.3 获取 OnlineSubsystem](#9.3 获取 OnlineSubsystem)
- [9.4 创建 Session(开房间)](#9.4 创建 Session(开房间))
- [9.5 查找 Session(搜索房间)](#9.5 查找 Session(搜索房间))
- [9.6 加入 Session(进房间)](#9.6 加入 Session(进房间))
- [9.7 销毁 Session(关闭房间)](#9.7 销毁 Session(关闭房间))
- [9.8 测试用的 NullSubsystem](#9.8 测试用的 NullSubsystem)
- [十、关卡跳转:Server Travel 与 Client Travel](#十、关卡跳转:Server Travel 与 Client Travel)
-
- [10.1 Travel 类型](#10.1 Travel 类型)
- [10.2 Server Travel(服务器跳转)](#10.2 Server Travel(服务器跳转))
- [10.3 Client Travel(客户端跳转)](#10.3 Client Travel(客户端跳转))
- [10.4 跳转时的参数传递](#10.4 跳转时的参数传递)
- [10.5 跨关卡状态保持](#10.5 跨关卡状态保持)
前言
这份手册旨在帮助具备C++基础、但无网络编程经验的开发者,理解UE5联机系统的完整工作方式,一篇文章带你直达天听。
手册包含以下内容:
- 网络传输层基础:Socket、TCP、UDP的原理与实现(含完整控制台代码)
- UE5运行模式:Standalone、Dedicated Server、Listen Server的区别与NetRole详解
- 同步架构:状态同步与帧同步的对比,以及UE5选择状态同步的原因
- 属性复制:如何用一行代码让变量自动同步,OnRep回调、复制条件、性能优化
- RPC:Server/Client/Multicast三种RPC的用法、Reliable vs Unreliable、防作弊验证
- 底层机制:ActorChannel、Bunch、数据流转、相关性裁剪
- 玩家生命周期:登录、登出、断线重连的完整实现
- 会话管理:Session的创建、查找、加入(OnlineSubsystem)
- 关卡跳转:Server Travel与Client Travel的区别与用法
一、网络传输层基础:从 Socket 到游戏协议
1.1 俩台电脑如何建立连接
1.1.1 从寄信开始说起
想象一下,你要给住在另一个城市的朋友寄一封信:
| 寄信的步骤 | 电脑网络里的对应概念 |
|---|---|
| 你知道朋友的家庭住址(哪个省、哪个市、哪条街) | IP地址:定位到某一台电脑,比如 192.168.1.100 |
| 信到了朋友家,谁收信呢?信上写着"张三收" | 端口号:定位到电脑上的某一个程序,比如 7777 是游戏程序 |
| 你把信放进邮筒,邮差来取走 | Socket:你的程序用来发信和收信的那个"窗口" |
| 你选择挂号信(要回执)还是平信(扔进去就不管了) | 协议:TCP(要确认)还是UDP(发完就忘) |
一句话总结:
- IP地址 = 找到那台电脑
- 端口号 = 找到电脑上的那个程序
- Socket = 你程序里那个用来收发数据的"把手"
- 协议 = 你用什么样的方式寄这封信
1.1.2 什么是Socket?(重点)
Socket的中文意思是"插座",开发过程中会说套接字。这个名字非常形象:
text
你的程序 ⚡ Socket(插座,套接字) ⚡ 网线 ⚡ 对方的程序
你就像把一个插头插进插座,然后电流(数据)就流出去了。在代码里,Socket是一个变量(准确说是句柄),你拿着这个变量,就可以:
- 把数据塞进去(发送)
- 从里面取数据出来(接收)
cpp
// 伪代码:Socket 就像你手里的一个"收发器"
SOCKET mySocket = CreateSocket();
// 发送数据:把数据扔进Socket
send(mySocket, "你好", ...);
// 接收数据:从Socket里取出对方发来的东西
recv(mySocket, 接收缓冲区, ...);
在UE5里,你几乎不会直接操作Socket。引擎已经把它封装得严严实实了。但是,理解Socket的概念,能帮你理解"数据是怎么从你电脑跑到别人电脑的"。
1.1.3 客户端 vs 服务器
在网络编程里,有两个角色:
| 角色 | 做什么 | 生活中的例子 |
|---|---|---|
| 服务器 | 一直开着,等着别人来连接。提供"服务" | 餐厅的厨房:一直开着,等着接单做菜 |
| 客户端 | 主动去连接服务器,请求服务 | 顾客:主动走进餐厅,点菜 |
一个服务器可以同时服务很多个客户端。比如一个游戏的服务器,可能有几百个玩家同时连在上面。
小坑:在UE5里,"监听服务器"(Listen Server)既是服务器又是客户端,这个后面会细说,。
1.2 TCP
TCP的全称是 Transmission Control Protocol (传输控制协议)。名字里就有"控制"两个字,说明它很严谨。
1.2.1 TCP的核心特点
| 特点 | 解释 | 比喻 |
|---|---|---|
| 面向连接 | 发数据之前,必须先建立连接 | 打电话:先拨号,对方接了才能说话 |
| 可靠传输 | 数据一定到达,不丢不重,顺序不乱 | 挂号信:每一封都有回执,丢了就重寄 |
| 有流量控制 | 发得太快,对方处理不过来,就让你慢点发 | 你说话太快,对方说"慢点,我跟不上" |
| 有拥塞控制 | 网络堵了,就自动少发点 | 高速堵车了,大家自觉减速 |
好的,下面是一个完整、可直接拷贝的三次握手章节。它把字段定义、报文结构、三次握手步骤、状态变化、C++编程对应全部整合在一起,并且保证看得懂。
1.2.2 三次握手
TCP在正式发数据之前,必须先建立连接。建立连接的过程称为三次握手。
1.2.2.1 TCP报文中的关键字段
在理解三次握手之前,需要先认识TCP报文头部中的几个关键字段:
text
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 序列号(Sequence Number) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 确认号(Acknowledgment Number) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ... | |U|A|P|R|S|F| |
| | |R|C|S|S|Y|I| 其他字段(窗口、校验和等) |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
标志位详解(共6个,每个占1比特):
┌───┬───┬───┬───┬───┬───┐
│URG│ACK│PSH│RST│SYN│FIN│
└───┴───┴───┴───┴───┴───┘
↑ ↑ ↑ ↑ ↑ ↑
│ │ │ │ │ └── FIN:断开连接
│ │ │ │ └────── SYN:建立连接
│ │ │ └────────── RST:重置连接(异常终止)
│ │ └────────────── PSH:立即推送数据(不缓冲)
│ └────────────────── ACK:确认号字段有效
└────────────────────── URG:紧急指针有效
各字段含义
| 字段 | 全称 | 在报文中的位置 | 含义(通俗解释) |
|---|---|---|---|
| seq(序列号) | Sequence Number | 第1-4字节 | 给每个字节编号。这个字段的值表示本报文段第一个字节的编号 |
| ack(确认号) | Acknowledgment Number | 第5-8字节 | 只在ACK标志位=1时有效。表示期望接收方下次发送的字节编号,同时隐含确认该编号之前的所有字节都已收到 |
| SYN | Synchronize | 第13字节第2位 | 标志位。值为1表示请求建立连接 |
| ACK | Acknowledgment | 第13字节第4位 | 标志位。值为1表示确认号(ack)字段有效 |
核心规则(重要)
- SYN和FIN消耗一个序列号
即使SYN报文不携带应用数据,它也会占用一个序号。这就是为什么第①步的seq=x,到第③步变成seq=x+1(因为SYN被消耗掉了)。 - ACK标志与ack字段的关系
只有ACK标志位=1时,报文中的ack字段才有意义。在三次握手中,除了第①步的纯SYN报文,第②步和第③步的ACK标志位都是1。 - 初始序列号x和y是随机的
操作系统会随机生成初始序列号(ISN),而不是从0或1开始。这是为了防止历史连接的旧报文干扰新连接。
完整过程
下图展示了三次握手中每一步的报文内容和关键字段值:
服务器 客户端 服务器 客户端 我想连接,这是我的起始编号x 同意,我的起始编号是y, 我已收到你的x,请发x+1 我已收到你的y, 连接建立,开始传数据 ① SYN (seq=x) ② SYN+ACK (seq=y, ack=x+1) ③ ACK (seq=x+1, ack=y+1)
三步详解
| 步骤 | 方向 | 报文类型 | 发送方的seq | 发送方的ack | 发送方设置的标志位 | 实际含义(翻译成人话) |
|---|---|---|---|---|---|---|
| ① | 客户端→服务器 | SYN | x(随机数) | (无效,因为ACK=0) | SYN=1 | "服务器你好,我想建立连接。这是我的初始编号x,请做好准备。" |
| ② | 服务器→客户端 | SYN+ACK | y(随机数) | x+1 | SYN=1, ACK=1 | "客户端你好,我同意连接。这是我的初始编号y。另外,我收到了你的x,你下一个应该发编号x+1给我。" |
| ③ | 客户端→服务器 | ACK | x+1 | y+1 | ACK=1 | "服务器,我收到了你的y。你下一个应该发编号y+1给我。好了,连接已建立,我们可以开始传数据了。" |
为什么是三次,不是两次
三次握手的根本目的是:确认双方的发送能力和接收能力都正常。
| 步骤 | 完成后能确认的事情 |
|---|---|
| ① 客户端发SYN | 服务器确认:客户端能发(因为收到了SYN)、自己能收(因为收到了报文) |
| ② 服务器回SYN+ACK | 客户端确认:服务器能收(因为收到了自己的SYN)、服务器能发(因为它发来了SYN+ACK)、自己能收(因为收到了回复) |
| ③ 客户端回ACK | 服务器确认:客户端能收(因为收到了ACK) |
如果只做两次握手(即客户端发SYN、服务器回SYN+ACK后就认为连接建立),会造成什么问题?
- 服务器无法确认客户端是否具备接收能力。
- 如果客户端发送的SYN在网络中延迟,客户端超时重传了一个新SYN,连接建立后,那个延迟的旧SYN才到达服务器。服务器会误以为客户端又要建立新连接,从而浪费资源。
- 历史连接中滞留在网络中的旧SYN报文,可能会意外建立起一个无效连接。
三次握手通过客户端的最后一次ACK,让服务器明确知道"客户端确实能收",从而避免了上述问题。
1.2.2.3 三次握手中的状态变化
TCP连接是有状态的。三次握手过程中,客户端和服务器会在不同状态之间迁移:
| 步骤 | 发生的事件 | 客户端状态 | 服务器状态 |
|---|---|---|---|
| 开始 | 服务器启动监听 | CLOSED | LISTEN(等待连接) |
| ① 之前 | 客户端决定发起连接 | SYN_SENT(已发SYN) | LISTEN |
| ① 之后 | 服务器收到SYN | SYN_SENT | SYN_RCVD(已收到SYN,待确认) |
| ② 之后 | 客户端收到SYN+ACK | ESTABLISHED(连接已建立) | SYN_RCVD |
| ③ 之后 | 服务器收到ACK | ESTABLISHED | ESTABLISHED |
当双方都进入 ESTABLISHED 状态时,TCP连接正式建立,可以开始传输应用数据。
1.2.2.4 与C++编程的对应关系
重要理解 :三次握手是由操作系统内核自动完成的,应用程序(你用C++写的代码)只需要调用标准的socket函数,不需要手动处理SYN、ACK等细节。
| C++函数调用 | 内核自动完成的工作 | 对应握手的哪一步 |
|---|---|---|
| 服务器端:listen() | 内核进入LISTEN状态,等待客户端的连接请求 | 准备接收步骤① |
| 客户端:connect() | 内核自动发送SYN(步骤①)→ 等待收到SYN+ACK → 自动发送ACK(步骤③)→ 然后connect()才返回 | 全部三步 |
| 服务器端:accept() | 内核在收到客户端的SYN后,自动回复SYN+ACK(步骤②)→ 再收到客户端的ACK(步骤③)→ 然后accept()返回一个新的已连接套接字 | 全部三步 |
代码示例
cpp
// 客户端:这一行调用完成后,三次握手已经全部完成
int ret = connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret == 0) {
// 连接已建立,可以直接 send() 发送数据
send(sockfd, data, len, 0);
}
// 服务器端:这一行调用返回时,三次握手也已经全部完成
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addrlen);
if (client_fd >= 0) {
// 连接已建立,可以直接 recv() 接收数据
recv(client_fd, buffer, sizeof(buffer), 0);
}
关于网络丢包 :如果SYN或SYN+ACK在网络中丢失,内核会自动重传 。客户端的connect()会阻塞等待,直到握手成功或超时(通常75秒左右)。这些重传对应用层是透明的,你不需要在代码中处理。
常见误区澄清
| 误区 | 正确理解 |
|---|---|
| 三次握手是为了协商参数(如窗口大小、MSS) | 协商参数是顺便做的,核心目的是确认双方的收发能力 |
| 第三次的ACK报文可以携带数据 | 技术上是允许的(这叫"数据和ACK搭车发送"),但绝大多数TCP实现中,第三次握手仍然是纯ACK |
| 初始序列号x和y总是从0或1开始 | 不对。现代操作系统会随机生成初始序列号,这是安全机制(防止TCP序列号预测攻击) |
| 只有SYN报文才设置SYN标志位 | 正确。SYN标志位只在连接建立阶段的前两个报文(SYN和SYN+ACK)中为1 |
补充:SN、长度与ACK的关系
理解确认号(ack)如何计算,对理解TCP的可靠性很有帮助。下面是一个典型的数据传输例子:
接收方 发送方 接收方 发送方 携带第1003~1022号字节 含义:"我已收到1003~1022, 请从第1023号开始发送" TCP报文段 (SN=1003, 长度=20) 计算:1003 + 20 = 1023 ACK报文 (ACK=1, ack=1023)
公式 :接收方回复的ack = 收到的SN + 接收到的数据长度
注意 :对于SYN报文(数据长度为0),ack = 收到的SN + 1,因为SYN本身消耗一个序列号。
这个完整版本从字段定义、报文结构、握手步骤、原因分析、状态变化、代码对应、误区澄清到补充例子,层层递进。您可以直接拷贝作为文档的"三次握手"章节。
1.2.3 可靠性是怎么实现的?
TCP保证可靠的核心机制是:确认 + 超时重传。
接收方 发送方 接收方 发送方 收到 ⚠️ 这个包丢了! 等了一会儿,没收到确认 重传成功! 发送包 确认包 发送包 确认包 发送包 重新发送包 确认包
简单说:发一个包,必须收到对方的"我收到了"的回执,才能发下一个。收不到回执就重新发。
1.2.4 TCP的缺点:队头"阻塞"
这是TCP最让人头疼的问题。
接收方 发送方 接收方 发送方 发送队列: [包1] [包2] [包3] [包4]... ✅ 已收包1 ✅ 已收包2 ❌ 包3丢了! ⏳ 收到包4,但包3还没到 先存着,等包3 等待包3重传... ✅ 包3到了 现在把包3和包4一起交给程序 包1 ACK 1 包2 ACK 2 包3 包4 重新发送 包3 ACK 3
包3丢了 → 包4虽然已经到了,但不能交给程序(因为顺序不对,3还没到)→ 必须等包3重传并到达 → 包4才能被处理。
1.2.5 亲手写一个TCP程序(控制台版)
准备工作(Windows环境):
- 创建一个新的C++控制台项目
- 项目 → 属性 → C++ → 预处理器 → 预处理器定义,添加
_WINSOCK_DEPRECATED_NO_WARNINGS - 确保链接了
ws2_32.lib(代码里用#pragma comment做了)

TCP服务器代码(完整可运行):
cpp
// ========== TCP_Server.cpp ==========
// 这个程序会:创建一个服务器,等待客户端连接,收到连接后发送一条消息
#include <iostream>
#include <WinSock2.h> // Windows Socket 的头文件
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib") // 告诉编译器链接这个网络库
int main() {
// ========== 第1步:初始化网络库(Windows特有,其他系统不同) ==========
WORD wVersion; // 版本号,比如1.1或2.2
WSADATA wsaData; // 用来接收网络库的详细信息
int err;
wVersion = MAKEWORD(1, 1); // MAKEWORD(1,1) 表示版本 1.1
err = WSAStartup(wVersion, &wsaData); // 启动网络库
if (err != 0) {
std::cout << "网络库初始化失败!" << std::endl;
return -1;
}
// 检查版本是不是我们请求的1.1
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1) {
WSACleanup(); // 清理网络库
return -1;
}
std::cout << "网络库初始化成功!" << std::endl;
// ========== 第2步:创建Socket(我们手里的"收发器") ==========
// 参数1 AF_INET:使用IPv4地址
// 参数2 SOCK_STREAM:使用TCP协议(流式传输)
// 参数3 0:自动选择协议
SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET) {
std::cout << "创建Socket失败!" << std::endl;
WSACleanup();
return -1;
}
std::cout << "Socket创建成功!" << std::endl;
// ========== 第3步:准备绑定信息(绑定到哪个IP和端口) ==========
SOCKADDR_IN serverAddr; // 存服务器自己的地址信息
serverAddr.sin_family = AF_INET; // 使用IPv4
serverAddr.sin_port = htons(6000); // 绑定到6000端口(htons转换字节顺序)
serverAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY); // 绑定本机所有网卡
// ========== 第4步:绑定(把Socket和IP+端口绑在一起) ==========
if (bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cout << "绑定失败!错误码:" << WSAGetLastError() << std::endl;
closesocket(listenSocket);
WSACleanup();
return -1;
}
std::cout << "绑定成功!正在监听6000端口..." << std::endl;
// ========== 第5步:监听(告诉系统,我要开始等别人来连接了) ==========
// 参数2 10:最多允许10个连接在队列里等待
if (listen(listenSocket, 10) == SOCKET_ERROR) {
std::cout << "监听失败!" << std::endl;
closesocket(listenSocket);
WSACleanup();
return -1;
}
// ========== 第6步:接受客户端连接(这个函数会"卡住",直到有人连进来) ==========
SOCKADDR_IN clientAddr; // 用来存放连接上来的客户端的地址
int clientAddrLen = sizeof(clientAddr);
std::cout << "等待客户端连接..." << std::endl;
SOCKET clientSocket = accept(listenSocket, (SOCKADDR*)&clientAddr, &clientAddrLen);
if (clientSocket == INVALID_SOCKET) {
std::cout << "接受连接失败!" << std::endl;
closesocket(listenSocket);
WSACleanup();
return -1;
}
// inet_ntoa 把数字形式的IP地址转成字符串,比如 "192.168.1.100"
std::cout << "客户端已连接!IP: " << inet_ntoa(clientAddr.sin_addr) << std::endl;
// ========== 第7步:收发数据 ==========
char sendBuf[] = "Hello Client! 欢迎连接服务器!\n";
// 发送数据
int bytesSent = send(clientSocket, sendBuf, strlen(sendBuf) + 1, 0);
if (bytesSent == SOCKET_ERROR) {
std::cout << "发送数据失败!" << std::endl;
} else {
std::cout << "已发送:" << sendBuf;
}
char recvBuf[100] = {0};
// 接收数据(这里也会"卡住",直到收到数据)
int bytesRecv = recv(clientSocket, recvBuf, 100, 0);
if (bytesRecv > 0) {
std::cout << "收到客户端消息:" << recvBuf << std::endl;
}
// ========== 第8步:关闭连接,清理 ==========
std::cout << "按任意键关闭服务器..." << std::endl;
std::cin.get();
closesocket(clientSocket);
closesocket(listenSocket);
WSACleanup();
return 0;
}
TCP客户端代码(配套上面的服务器):
cpp
// ========== TCP_Client.cpp ==========
#include <iostream>
#include <WinSock2.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")
int main() {
// 第1步:初始化网络库(和服务器一样)
WORD wVersion = MAKEWORD(1, 1);
WSADATA wsaData;
if (WSAStartup(wVersion, &wsaData) != 0) {
std::cout << "初始化失败" << std::endl;
return -1;
}
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1) {
WSACleanup();
return -1;
}
// 第2步:创建Socket
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) {
std::cout << "创建Socket失败" << std::endl;
WSACleanup();
return -1;
}
// 第3步:准备服务器的地址信息
SOCKADDR_IN serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(6000); // 服务器端口6000
serverAddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); // 127.0.0.1 是本机地址
// 第4步:连接服务器
std::cout << "正在连接服务器..." << std::endl;
if (connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cout << "连接服务器失败!" << std::endl;
closesocket(clientSocket);
WSACleanup();
return -1;
}
std::cout << "连接服务器成功!" << std::endl;
// 第5步:收发数据(先收服务器的欢迎消息)
char recvBuf[100] = {0};
recv(clientSocket, recvBuf, 100, 0);
std::cout << "收到服务器消息:" << recvBuf;
// 发送消息给服务器
char sendBuf[] = "你好服务器,我是客户端!";
send(clientSocket, sendBuf, strlen(sendBuf) + 1, 0);
std::cout << "已发送消息给服务器" << std::endl;
// 第6步:关闭
std::cout << "按任意键退出..." << std::endl;
std::cin.get();
closesocket(clientSocket);
WSACleanup();
return 0;
}
如何运行测试:
- 先运行
Server项目,你会看到"等待客户端连接..." - 再运行
Client项目,客户端会连上服务器 - 服务器端显示"客户端已连接",并发送欢迎消息
- 客户端收到消息,然后发消息给服务器
- 服务器收到客户端的消息
运行效果 :

1.3 UDP
UDP的全称是 User Datagram Protocol(用户数据报协议)。
1.3.1 UDP的核心特点
| 特点 | 解释 | 比喻 |
|---|---|---|
| 无连接 | 发数据前不需要建立连接,直接发 | 寄平信:填好地址扔邮筒,不需要提前打招呼 |
| 不可靠 | 数据可能丢,可能乱序,可能重复 | 平信可能寄丢,也可能前后顺序错乱 |
| 无流量/拥塞控制 | 不管对方能不能处理,发了再说 | 不管对方听不听得清,你只管说 |
| 开销小 | 只有8字节的头部 | 明信片很薄,邮费便宜 |
1.3.2 UDP的包结构(为什么它快)
text
TCP包(复杂,开销大):
[目标端口][源端口][序号][确认号][数据偏移][保留][标志位][窗口][校验和][紧急指针][选项...][数据...]
↑ 光是头部就有20-60字节
UDP包(简单,开销小):
[源端口][目标端口][长度][校验和][数据...]
↑ 只有8字节的头部
UDP快的秘密:
- 没有建立连接的开销(不用三次握手)
- 没有确认机制(发完就忘)
- 没有重传机制(丢了也不补)
- 头部很短(只占8字节)
1.3.3 亲手写一个UDP程序
UDP程序比TCP简单很多,因为不需要连接。
UDP服务器代码:
cpp
// ========== UDP_Server.cpp ==========
#include <iostream>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")
int main() {
// 第1步:初始化网络库(和TCP一样)
WORD wVersion = MAKEWORD(1, 1);
WSADATA wsaData;
WSAStartup(wVersion, &wsaData);
// 第2步:创建UDP Socket
// 注意:第二个参数是 SOCK_DGRAM(数据报),不是 SOCK_STREAM
SOCKET serverSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (serverSocket == INVALID_SOCKET) {
std::cout << "创建Socket失败" << std::endl;
return -1;
}
// 第3步:绑定端口(告诉系统,发给6001端口的UDP数据都交给我)
SOCKADDR_IN serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(6001);
serverAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
if (bind(serverSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cout << "绑定失败" << std::endl;
closesocket(serverSocket);
WSACleanup();
return -1;
}
std::cout << "UDP服务器已启动,端口6001,等待数据..." << std::endl;
// 第4步:收发数据(UDP不需要 accept,直接收就行)
char recvBuf[100];
char sendBuf[] = "Hello Client! 这是UDP服务器的回复!";
SOCKADDR_IN clientAddr;
int clientAddrLen = sizeof(clientAddr);
while (true) {
memset(recvBuf, 0, 100);
// recvfrom:接收数据,同时会告诉你是从哪个地址发来的
int bytesRecv = recvfrom(serverSocket, recvBuf, 100, 0,
(SOCKADDR*)&clientAddr, &clientAddrLen);
if (bytesRecv > 0) {
std::cout << "收到来自 " << inet_ntoa(clientAddr.sin_addr)
<< ":" << ntohs(clientAddr.sin_port)
<< " 的消息:" << recvBuf << std::endl;
// 回复消息(发回去)
sendto(serverSocket, sendBuf, strlen(sendBuf) + 1, 0,
(SOCKADDR*)&clientAddr, clientAddrLen);
std::cout << "已回复:" << sendBuf << std::endl;
}
}
closesocket(serverSocket);
WSACleanup();
return 0;
}
UDP客户端代码:
cpp
// ========== UDP_Client.cpp ==========
#include <iostream>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")
int main() {
// 初始化
WORD wVersion = MAKEWORD(1, 1);
WSADATA wsaData;
WSAStartup(wVersion, &wsaData);
// 创建UDP Socket
SOCKET clientSocket = socket(AF_INET, SOCK_DGRAM, 0);
// 准备服务器地址
SOCKADDR_IN serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(6001);
serverAddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// UDP不需要connect!直接发就行
char sendBuf[] = "你好服务器,这是UDP客户端!";
char recvBuf[100];
// 发送数据(需要指定目标地址)
int serverAddrLen = sizeof(serverAddr);
sendto(clientSocket, sendBuf, strlen(sendBuf) + 1, 0,
(SOCKADDR*)&serverAddr, serverAddrLen);
std::cout << "已发送:" << sendBuf << std::endl;
// 接收回复(会等待)
SOCKADDR_IN fromAddr;
int fromAddrLen = sizeof(fromAddr);
recvfrom(clientSocket, recvBuf, 100, 0,
(SOCKADDR*)&fromAddr, &fromAddrLen);
std::cout << "收到回复:" << recvBuf << std::endl;
closesocket(clientSocket);
WSACleanup();
std::cout << "按任意键退出..." << std::endl;
std::cin.get();
return 0;
}
运行效果:

1.3.4 UDP的丢包和乱序问题
丢包 :上面的例子中,如果网络不好,sendto 发出的数据可能根本到不了服务器。你的程序完全不知道。
乱序 :如果你连续发 "AAAA"、"BBBB"、"CCCC",对方可能收到 "AAAA"、"CCCC"、"BBBB"(顺序乱了)。
1.4 TCP vs UDP:游戏到底用哪个?
1.4.2 详细对比表
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接 | 需要(三次握手) | 不需要 |
| 可靠性 | 100%可靠 | 不保证 |
| 顺序 | 保证顺序 | 可能乱序 |
| 速度 | 较慢 | 很快 |
| 头部开销 | 20-60字节 | 8字节 |
| 丢包时 | 重传,但会阻塞后续包 | 不重传,后续包照常处理 |
| 适合场景 | 文件下载、网页、邮件、聊天 | 视频通话、游戏实时同步、DNS |
1.4.3 游戏里分别用在什么地方
用TCP的地方(UE5中较少):
- 登录验证(必须成功,慢点没关系)
- 匹配和房间信息(读取服务器上的房间列表)
- 聊天消息(每一句都要让对方看到)
- 下载补丁/DLC(文件必须完整)
用UDP的地方(UE5主角):
- 角色位置同步:高频,允许丢包(丢包→瞬移/拉扯)
- 开火/移动输入:高频,追求速度
- 属性复制(血量/分数等):自动状态同步
- RPC:可选Reliable或Unreliable*
想象一下:
- 用TCP :你开了一枪,这个"开枪包"丢了。后面的"移动包"、"视角包"都得等"开枪包"重传。你会感觉到角色突然卡住,然后瞬移。
- 用UDP:你开了一枪,"开枪包"丢了。但后面的"移动包"正常到达,你的角色还在移动。只是那一枪没打出来(或者客户端自己做个特效假装开了,等服务器确认)。体验比卡住好得多。
1.5 UE的流程
后续会系统讲这部分
你的代码
标记Replicated / 调用RPC
UE5 NetDriver
打包成Bunch
传输协议
UDP Socket
(主力)
网络
TCP Socket
(辅助)
二、UE5的联机流程
2.1 服务器与客户端
在开始讲UE5之前,我们先确保你理解最基础的概念。
2.1.1 一个生活中的例子
你和朋友联机打游戏,有两种方式:
| 方式 | 怎么玩的 | 对应的游戏模式 |
|---|---|---|
| 方式1 | 你们俩都连到一个专门的房间(比如网吧的服务器),房间里没有屏幕,只有电脑主机在跑。你们俩各自用自己的电脑连接进去。 | 专用服务器模式 |
| 方式2 | 你自己开一个房间,你就是房主。你的电脑既运行游戏(你自己在玩),又当服务器(处理其他人的数据)。你朋友连到你的电脑上。 | 监听服务器模式 |
方式1的优缺点:
- ✅ 公平(房主没有优势)
- ✅ 稳定(专门的主机,不卡)
- ✅ 房主退出不影响游戏(游戏还在服务器上跑)
- ❌ 需要额外花钱租服务器
方式2的优缺点:
- ✅ 方便(不需要额外服务器)
- ✅ 适合小范围联机(比如朋友之间)
- ❌ 房主有优势(延迟最低)
- ❌ 房主退出,游戏就结束了
2.1.2 "权威"这个概念(非常重要!)
核心规则:服务器说了算,客户端只是"建议"
其他客户端 服务器 客户端 其他客户端 服务器 客户端 先显示特效(为了手感好) 检查弹道、距离、障碍物 所有客户端更新血条显示 玩家开枪 RPC: "我开枪了,打中了敌人" 验证通过,敌人扣血 (100→80) 属性复制: 敌人血量=80 属性复制: 敌人血量=80
为什么要这样?
如果让客户端自己说了算,会发生什么?
- 你的客户端说:"我打中敌人了,敌人死了!"
- 但你的网络有延迟,敌人其实已经躲到墙后面了
- 你的客户端因为延迟,看到的还是敌人没躲进去的画面
- 结果:你觉得自己打中了,但服务器判定没打中 → 俗称"吞子弹"
所以:服务器是最终裁判,客户端不能自己做主。
2.2 UE5的三种运行模式
2.2.1 模式1:Standalone(独立模式&单机模式)
这是什么:
- 就是传统的单机游戏
- 只有一个进程,同时做"服务器的事"和"客户端的事"
- 没有网络通信
什么时候用:
- 开发时快速测试(不需要开两个窗口)
- 纯单机游戏(不联机)
代码里怎么判断:
cpp
if (GetWorld()->GetNetMode() == NM_Standalone)
{
// 这是单机模式
// 这里的代码既不会发给服务器,也不会发给客户端
// 因为根本没有服务器和客户端之分
}
2.2.2 模式2:Dedicated Server(专用服务器)
这是什么:
- 一个没有画面的程序,只运行游戏逻辑
- 专门用来当"裁判"
- 通常运行在云服务器或者独立的机器上
重要 :专用服务器不渲染任何画面,也没有声音、没有UI。它只做计算。
什么时候用:
- 正式上线的大型游戏(比如《绝地求生》、《英雄联盟》)
- 你需要24小时开着的服务器
代码里怎么判断:
cpp
if (GetWorld()->GetNetMode() == NM_DedicatedServer)
{
// 这是专用服务器模式
// 这里不会显示任何画面
// 可以在这里写刷怪、计分、判断胜负等逻辑
}
// 更常用的写法(因为Listen Server也是服务器):
if (GetWorld()->IsServer())
{
// 任何模式的服务器(专用服务器 或 监听服务器)都会进来
}
if (GetWorld()->IsClient())
{
// 任何模式的客户端都会进来
// 注意:监听服务器的房主既是服务器也是客户端,所以也会进来
}
示意图:
🖥️ 朋友的电脑
🖥️ 你的电脑
☁️ 云服务器
网络
网络
🎮 专用服务器
(无渲染 / 仅逻辑)
运行在机房
有画面
连接服务器
有画面
连接服务器
2.2.3 模式3:Listen Server(监听服务器)
这是什么:
- "房主模式"
- 一个程序同时是服务器和客户端
- 房主自己也在玩游戏,同时别人也连到他这里
什么时候用:
- 小范围联机(朋友之间)
- 开发测试(不需要单独开一个服务器窗口)
- 合作游戏(《我的世界》、《饥荒》、泰拉瑞亚等开房间类型)
注意:房主的电脑压力最大(要跑游戏画面,还要处理所有人的数据)。
代码里怎么判断:
cpp
if (GetWorld()->GetNetMode() == NM_ListenServer)
{
// 这是监听服务器模式
// 这个进程既是服务器又是客户端
}
示意图:
网络
网络
👤 另一个朋友
只有客户端
👤 朋友的电脑
只有客户端
🏠 房主的电脑
⚙️ 服务器逻辑
(裁判)
🎮 客户端逻辑
(自己玩)
2.2.4 三种模式对比表
| 对比项 | Standalone | Dedicated Server | Listen Server |
|---|---|---|---|
| 有没有画面 | 有 | 无 | 有 |
| 谁当服务器 | 自己 | 单独的程序 | 房主自己 |
| 谁当客户端 | 自己 | 所有玩家 | 所有玩家(包括房主) |
| 性能消耗 | 低 | 最低(无渲染) | 最高(同时干两件事) |
| 房主退出影响 | --- | 不影响 | 游戏结束 |
| 公平性 | --- | 最公平 | 房主有优势 |
| 适合场景 | 单机、测试 | 正式上线 | 朋友联机、开发测试 |
2.3 如何判断当前模式
cpp
// 方法1:通过 UWorld 的 GetNetMode()
UWorld* World = GetWorld();
ENetMode NetMode = World->GetNetMode();
switch (NetMode)
{
case NM_Standalone:
// 单机模式
break;
case NM_DedicatedServer:
// 专用服务器模式
break;
case NM_ListenServer:
// 监听服务器模式(房主)
break;
case NM_Client:
// 纯客户端模式
break;
}
// 方法2:最常用的两个判断
if (World->IsServer())
{
// 任何模式的服务器(专用服务器 或 监听服务器)
// 在这里写只有服务器才能做的逻辑
// 例如:刷怪、判断胜负、计算伤害
}
if (World->IsClient())
{
// 任何模式的客户端(包括监听服务器的房主)
// 在这里写只有客户端才能做的逻辑
// 例如:显示UI、播放声音、处理输入
}
一个小坑:
cpp
// 监听服务器的房主:
// IsServer() → true (因为它是服务器)
// IsClient() → true (因为它也是客户端,自己也在玩)
// 专用服务器的程序:
// IsServer() → true
// IsClient() → false(没有画面,也不是客户端)
// 纯客户端:
// IsServer() → false
// IsClient() → true
2.4 NetRole:同一个Actor的不同"身份"(重点!)
这是初学者最容易搞混的地方。我们来慢慢讲。
2.4.1 为什么需要 NetRole?
场景:你和朋友联机玩《我的世界》。你的角色是一个拿着剑的史蒂夫。
- 在你的电脑上 :这个史蒂夫是你自己控制的,你按W它就往前走,你点鼠标它就挥剑。
- 在朋友的电脑上 :他看到你的史蒂夫在跑、在挥剑。但他不能控制你的角色。
问题:同一个"史蒂夫"角色,在你的电脑上和朋友的电脑上,扮演的角色(身份)完全不同!
NetRole 就是用来区分这个"身份"的。
2.4.2 三种 NetRole
| 角色 | 英文名 | 谁拥有 | 做什么 |
|---|---|---|---|
| 权威端 | Authority | 服务器 | 做最终决定,拥有"正确答案" |
| 自治代理&主控端 | Autonomous Proxy | 你自己控制的那个客户端 | 发送你的操作,做预测显示 |
| 模拟代理&模拟端 | Simulated Proxy | 其他客户端 | 显示其他玩家在做什么 |
2.4.3 一个具体例子
你在玩《守望先锋》,你选的角色是"猎空"(Tracer)。同时还有一个队友是"莱因哈特"(Reinhardt)。
服务器上:
text
猎空对象:Role = Authority(服务器说了算)
莱因哈特对象:Role = Authority(服务器说了算)
服务器对两个角色都有最终决定权。你开一枪,服务器决定是否命中。
你的电脑(控制猎空):
text
猎空对象:Role = Autonomous Proxy(你自己控制)
莱因哈特对象:Role = Simulated Proxy(你看到队友在动)
队友的电脑(控制莱因哈特):
text
猎空对象:Role = Simulated Proxy(他看你动)
莱因哈特对象:Role = Autonomous Proxy(他自己控制)
图示:
🖥️ 队友的电脑
🖥️ 你的电脑
🏢 服务器(Authority)
复制
复制
复制
复制
你控制
他控制
猎空对象
Role: Authority
(我说猎空在哪就在哪)
莱因哈特对象
Role: Authority
(我说莱因在哪就在哪)
猎空对象
Role: Autonomous
(你自己控制)
莱因哈特对象
Role: Simulated
(看别人动)
猎空对象
Role: Simulated
(看别人动)
莱因哈特对象
Role: Autonomous
(他自己控制)
2.4.4 代码里怎么判断 NetRole?
cpp
void AMyCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 判断1:我是服务器吗?(做权威逻辑)
if (HasAuthority())
{
// 只有服务器会执行这里
// 用途:检查碰撞、减少血量、生成怪物、判断胜负
CheckTrapDamage();
RegenerateHealth(DeltaTime);
}
// 判断2:这是我控制的角色吗?
if (IsLocallyControlled())
{
// 这里只有你控制的角色会执行
// 用途:处理键盘输入、更新UI、播放第一人称特效
float MoveForward = GetInputAxisValue("MoveForward");
AddMovementInput(GetActorForwardVector(), MoveForward);
}
// 判断3:这是别人控制的角色吗?(模拟端)
if (GetLocalRole() == ROLE_SimulatedProxy)
{
// 这里只有"你看别人在动"的角色会执行
// 用途:平滑移动、播放第三人称动画
SmoothInterpolateMovement(DeltaTime);
}
}
注意:怪物在服务器是权威,在客户端是模拟端
2.5 实战:让你写的函数只在正确的地方执行
2.5.1 判断表(收藏这个表)
| 你想做的事情 | 应该写在哪个判断里 | 代码 |
|---|---|---|
| 刷怪、计分、判断胜负 | 服务器(Authority) | if (HasAuthority()) |
| 处理WASD输入 | 本地控制的角色 | if (IsLocallyControlled()) |
| 更新血条UI | 客户端(所有客户端) | if (IsClient()) |
| 播放其他人移动的动画 | 模拟端(Simulated Proxy) | if (GetLocalRole() == ROLE_SimulatedProxy) |
| 生成只在本地显示的粒子特效 | 客户端(所有客户端) | if (IsClient()) |
| 验证玩家是否作弊 | 服务器 | if (HasAuthority()) |
三、同步架构流派:状态同步 vs 帧同步
3.1 什么是"同步"?------联机游戏最核心的问题
3.1.1 问题场景
你和朋友联机玩《双人成行》。你们俩的角色在同一个世界里,你搬起一块石头,朋友的屏幕上也要显示"你搬起了石头"。
问题:怎么保证你们俩屏幕上看到的东西是一样的?
这就是"同步"要解决的问题。
3.1.2 两种方案
| 方案 | 核心思路 | 比喻 |
|---|---|---|
| 状态同步 | 服务器存着"正确答案",客户端只负责显示 | 老师在黑板上写答案,学生照着抄 |
| 帧同步 | 所有人都自己算,但保证大家的输入是一样的 | 所有人用同一个计算器,按同样的按钮,得到同样的结果 |
3.2 状态同步(State Synchronization)------ UE5的方案
3.2.1 核心思路
一句话:服务器持有完整的游戏世界,客户端只拿到自己需要的部分。
具体流程:
text
时间线:
──────────────────────────────────────────────────────────
第1帧:
服务器:玩家A位置(100, 200),玩家B位置(300, 400),宝箱在(500, 600)
↓ 服务器打包这些状态
↓ 发送给客户端A(玩家A自己)→ 只发玩家A关心的
↓ 发送给客户端B(玩家B自己)→ 只发玩家B关心的
↓ 发送给客户端C(观战者)→ 发全部
第2帧:
客户端A:收到状态包 → 更新自己的屏幕
客户端B:收到状态包 → 更新自己的屏幕
服务器:游戏继续,玩家A移动到(110, 200)
↓ 检测到玩家A位置变了
↓ 只发送"玩家A位置变成(110,200)"这个变化
↓ 客户端收到,更新
第3帧、第4帧... 循环
3.2.2 状态同步的优点
| 优点 | 解释 |
|---|---|
| 防作弊容易 | 服务器是权威,客户端只能"建议",无法直接修改状态 |
| 新玩家加入友好 | 新玩家连上来,服务器直接把当前状态发给他,不需要回放历史 |
| 断线重连友好 | 掉线的玩家重连后,服务器直接发当前状态,瞬间恢复 |
| 网络要求低 | 不需要太高的带宽(只同步变化的部分) |
| 开发便利 | 引擎自动处理增量同步,你只需要标记 Replicated |
3.2.3 状态同步的缺点
| 缺点 | 解释 |
|---|---|
| 带宽峰值 | 大量属性同时变化时(比如爆炸范围内10个人同时掉血),带宽会瞬间飙升 |
| 延迟敏感 | 从状态变化到客户端看到,有一个网络往返的时间 |
| 需要插值 | 位置等运动属性需要做平滑和预测,否则看起来会卡顿 |
| 服务器压力大 | 服务器要运行完整的游戏逻辑(物理、AI、碰撞),成本高 |
3.3 帧同步(Lockstep / Frame Synchronization)------ 另一种方案
3.3.1 核心思路
一句话:只同步玩家的"操作",每个客户端自己计算结果。
具体流程:
text
时间线:
──────────────────────────────────────────────────────────
帧1(0ms):
客户端A:玩家按了W键 → 发送给服务器
客户端B:玩家按了D键 → 发送给服务器
客户端C:玩家按了J键(跳跃)→ 发送给服务器
↓
服务器:收集所有客户端的输入(帧1的输入集合)
↓
帧1结束(33ms):
服务器:把"帧1的输入集合"广播给所有客户端
↓
帧2(33ms):
客户端A:收到帧1的输入集合 → 自己演算 → 得到帧1的结果
客户端B:收到帧1的输入集合 → 自己演算 → 得到帧1的结果
客户端C:收到帧1的输入集合 → 自己演算 → 得到帧1的结果
↓
三个客户端自己算出来的结果是一样的!
↓
(同时,客户端们继续发送帧2的输入)
帧2结束(66ms):
服务器:广播"帧2的输入集合"
...
3.3.2 帧同步的关键:确定性
什么是确定性?
同样输入 + 同样初始状态 = 同样结果
UE5里哪些东西不是确定性的?
| 不确定性来源 | 例子 | 为什么不确定 |
|---|---|---|
| 浮点数计算 | 0.1 + 0.2 在不同CPU上结果可能不同 | 浮点数精度因CPU、编译器而异 |
| 随机数 | FMath::Rand() | 每台电脑的随机种子不同 |
| 物理模拟 | PhysX物理引擎 | 帧率不同时,物理模拟结果不同 |
| 多线程 | 异步任务执行顺序 | 不同电脑的执行顺序可能不同 |
| 时间相关 | GetWorld()->GetTimeSeconds() | 每台电脑的时间不同 |
这意味着 :UE5的物理、动画、AI、随机数,在不同电脑上执行同一段代码,结果可能不一样。这直接导致UE5不适合做帧同步。
3.3.3 帧同步的优点
| 优点 | 解释 |
|---|---|
| 带宽极小 | 只同步玩家的操作,不同步状态。100个玩家也只需要几KB/秒 |
| 服务器压力小 | 服务器只是转发输入,不需要跑物理和AI |
| 观战友好 | 观战者只需要拿到输入流,就能完全重现比赛 |
| 回放功能容易 | 只需要保存输入序列,就能回放整场比赛 |
3.3.4 帧同步的缺点
| 缺点 | 解释 |
|---|---|
| 新玩家加入困难 | 新玩家需要一个"快照"(当前状态),否则要从第一帧开始算 |
| 断线重连困难 | 掉线的玩家需要追赶所有错过的帧,可能需要好几秒 |
| 防作弊困难 | 每个客户端自己算结果,理论上可以修改本地代码作弊 |
| 确定性要求极高 | 任何不确定因素(浮点数、随机数)都会导致不同步 |
| 延迟影响大 | 必须等所有玩家的输入都到齐,才能推进帧(所以有"卡输入"的感觉) |
3.3. 帧同步的适用场景
| 游戏类型 | 原因 |
|---|---|
| 格斗游戏(《街霸》、《拳皇》) | 只需要同步几个按钮,对延迟敏感,回放功能重要 |
| RTS(《星际争霸》、《帝国时代》) | 单位数量多,但操作数量少,帧同步省带宽 |
| 体育游戏(《FIFA》) | 操作简单,对准确性要求高 |
| 派对游戏(《人类一败涂地》) | 物理确定性可以通过专门设计实现 |
3.4 详细对比表
| 对比维度 | 状态同步(UE5原生) | 帧同步 |
|---|---|---|
| 带宽消耗 | 中等~高(同步属性变化) | 极低(只同步输入) |
| 服务器负载 | 高(需要跑完整游戏逻辑) | 低(只转发/验证输入) |
| 滞后加入(Late Join) | ✅ 原生支持 | ❌ 需要复杂的状态追赶机制 |
| 断线重连 | ✅ 轻量(同步当前状态快照) | ❌ 重量(需要重放所有历史帧) |
| 防作弊 | ✅ 容易(服务器权威验证) | ❌ 困难(客户端可改本地逻辑) |
| 网络抖动处理 | 预测 + 插值平滑 | 需要输入缓冲 + 回滚(GGPO技术) |
| 确定性要求 | 无要求 | 极高(浮点、随机数都需同步) |
| 回放功能 | 需要存状态快照 | 只需要存输入序列 |
| 典型游戏类型 | FPS、TPS、MOBA、MMO | RTS、格斗游戏、平台跳跃 |
| UE5支持情况 | ✅ 原生、成熟 | ❌ 需要自己实现或插件 |
3.5 为什么UE5选择状态同步?
3.5.1 技术原因
- UE5的底层不是确定性的
- 物理引擎(PhysX)在不同帧率下结果不同
- 浮点数计算在不同CPU上有误差
- 动画系统的混合计算依赖时间
- UE5的设计目标是大世界、复杂场景
- 帧同步适合小规模、确定性强的游戏
- 状态同步适合开放世界、大量可交互物体
- UE5的复制系统天生就是状态同步
- 属性复制、RPC这些机制,从设计之初就是为状态同步准备的
3.5.2 商业原因
| 需求 | 状态同步 | 帧同步 |
|---|---|---|
| 快速开发原型 | ✅ 标记Replicated就能同步 | ❌ 需要处理确定性问题 |
| 支持大量玩家(100+) | ⚠️ 服务器压力大,但可行 | ✅ 带宽压力小 |
| 支持开放世界 | ✅ 距离裁剪、相关性系统 | ❌ 必须同步所有玩家的输入 |
| 支持Mod/脚本 | ✅ 服务器可验证 | ❌ 客户端脚本可能破坏确定性 |
| 开发成本 | 低(引擎自带) | 高(需要专门的架构) |
3.5.3 UE5官方的选择
Epic Games用UE5做的《堡垒之夜》(Fortnite)就是典型的状态同步:
- 100个玩家同场竞技
- 可破坏的建筑(大量状态变化)
- 支持跨平台(PC、主机、手机、Switch)
如果用帧同步,光是"100个玩家的输入同步"就是个大问题,更别提确定性了。
3.6 在UE5里怎么写状态同步的代码
3.6.1 最小示例
cpp
// 一个会自己同步状态的Actor
UCLASS()
class AStateSyncActor : public AActor
{
GENERATED_BODY()
public:
AStateSyncActor()
{
// 必须开启复制
bReplicates = true;
}
// 需要同步的属性
UPROPERTY(Replicated)
float Health = 100.0f;
UPROPERTY(Replicated)
FVector CurrentLocation;
// 注册属性
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AStateSyncActor, Health);
DOREPLIFETIME(AStateSyncActor, CurrentLocation);
}
// 修改属性(只能在服务器)
void TakeDamage(float Damage)
{
if (!HasAuthority()) return; // 只有服务器执行
Health -= Damage;
// 修改后,Health会自动同步到所有客户端
}
};
3.6.2 位置同步(角色移动)
UE5的角色移动组件(CharacterMovementComponent)已经内置了状态同步:
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AMyCharacter()
{
// 开启角色移动的复制
bReplicates = true;
// UE5自动同步角色的位置、旋转、速度
// 只需要配置这个:
GetCharacterMovement()->SetReplicatedMovementMode(EReplicatedMovementMode::Location); // 同步位置
GetCharacterMovement()->NetworkSimulatedSmoothLocationTime = 0.1f; // 平滑时间
}
};
3.6.3 状态同步的优化技巧
cpp
// 技巧1:降低不重要的Actor的更新频率
AMyPickup::AMyPickup()
{
NetUpdateFrequency = 2.0f; // 每秒只同步2次(默认是100次)
MinNetUpdateFrequency = 0.5f; // 最少0.5秒同步一次
}
// 技巧2:使用复制条件,只同步给需要的玩家
DOREPLIFETIME_CONDITION(AMyWeapon, Ammo, COND_OwnerOnly); // 弹药只同步给武器主人
// 技巧3:使用更小的数据类型
UPROPERTY(Replicated)
uint8 Level; // 1字节,而不是int32的4字节
// 技巧4:只在变化时通知
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
UFUNCTION()
void OnRep_Health()
{
// 只有Health变化时才调用
}
四、核心同步机制:属性复制(Replication)
4.1 什么是属性复制?
4.1.1 一个没有属性复制的世界
想象你在做一个联机游戏,玩家有血量。
没有属性复制的做法:
cpp
// ❌ 传统做法:手动写网络代码
void AMyCharacter::TakeDamage(float Damage)
{
Health -= Damage;
// 手动打包数据
FBufferWriter Writer;
Writer << Health;
// 手动发送给所有客户端
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
if (APlayerController* PC = It->Get())
{
PC->ClientSendHealth(Writer.GetData(), Writer.GetSize());
}
}
}
// 手动写接收代码
void AMyCharacter::ClientReceiveHealth(const TArray<uint8>& Data)
{
FBufferReader Reader(Data);
Reader << Health;
// 手动更新UI
UpdateHealthUI();
}
问题:
- 每个变量都要写一遍打包/解包代码
- 变量多了根本写不完
- 容易出错(忘了同步某个变量)
- 新玩家加入要手动发所有状态
4.1.2 有属性复制的世界
UE5的做法:只需要加一个关键字!
cpp
// ✅ UE5做法:标记一下就行
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 就这一行!告诉UE5:这个变量需要自动同步
UPROPERTY(Replicated)
float Health = 100.0f;
// 告诉UE5哪些属性要复制
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health); // 注册Health属性
}
void TakeDamage(float Damage)
{
if (!HasAuthority()) return; // 只在服务器修改
Health -= Damage; // 服务器修改后,自动同步给所有客户端!
// 不需要任何手动发送代码!
}
};
就这么简单! 服务器修改 Health 后,UE5自动:
- 检测到值变化了
- 打包变化的数据
- 发送给所有相关的客户端
- 客户端收到后自动更新本地
Health值
4.1.3 属性复制的流程(通俗版)
💻 客户端 🌐 网络 🏢 服务器 💻 客户端 🌐 网络 🏢 服务器 Health = 100 (旧值) Health = 80 (新值) 检测到变化 收到 "Health 变成 80" 自动更新 Health = 80 (可选) 调用 OnRep_Health() UI更新血条 打包 "Health 变成 80" 传输数据包
4.2 怎么用属性复制?(三步走)
步骤1:在变量声明上加 UPROPERTY(Replicated)
cpp
UPROPERTY(Replicated)
float Health;
UPROPERTY(Replicated)
int32 Score;
UPROPERTY(Replicated)
FVector Position;
步骤2:在 GetLifetimeReplicatedProps 里注册
cpp
void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 每注册一个
DOREPLIFETIME(AMyActor, Health);
DOREPLIFETIME(AMyActor, Score);
DOREPLIFETIME(AMyActor, Position);
}
步骤3:只在服务器上修改这个变量的值
cpp
// ✅ 正确:在服务器修改
void AMyActor::ServerSetHealth(float NewHealth)
{
if (HasAuthority()) // 确保是服务器
{
Health = NewHealth; // 修改后自动同步
}
}
// ❌ 错误:在客户端修改
void AMyActor::ClientSetHealth(float NewHealth)
{
Health = NewHealth; // 客户端改了,服务器不知道,也不会同步
}
就这么三步!
4.3 当属性变化时执行代码(OnRep)
4.3.1 为什么需要 OnRep?
血量变化了,你需要:
- 更新屏幕上的血条UI
- 播放受伤特效
- 触发角色的受伤动画
这些事应该在客户端做(因为服务器没有画面)。
4.3.2 OnRep 的用法
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 用 ReplicatedUsing 代替 Replicated
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health = 100.0f;
// 这个函数在客户端收到新的 Health 值时自动调用
UFUNCTION()
void OnRep_Health()
{
// 这里只在客户端执行
// 参数:不需要传,直接读 Health 的新值就行
UpdateHealthBar(); // 更新血条
PlayHitEffect(); // 播放受击特效
if (Health <= 0)
{
PlayDeathAnimation(); // 播放死亡动画
}
}
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health);
}
void TakeDamage(float Damage)
{
if (!HasAuthority()) return;
Health = FMath::Max(0.0f, Health - Damage);
// Health 被修改后:
// 1. 自动同步给客户端
// 2. 客户端自动调用 OnRep_Health()
}
private:
void UpdateHealthBar()
{
// 更新UI代码...
}
};
4.3.3 OnRep 的触发时机
| 情况 | OnRep 是否调用 |
|---|---|
| 服务器首次生成Actor,发送初始值 | ✅ 调用 |
| 服务器的值发生变化,客户端收到 | ✅ 调用 |
| 同一个值重复设置(100→100) | ❌ 默认不调用 |
| 客户端新加入,收到当前状态 | ✅ 调用 |
4.3.4 强制重复值也触发 OnRep
cpp
// 加上 REPNOTIFY_Always,即使值没变也触发
DOREPLIFETIME_CONDITION_NOTIFY(AMyCharacter, Health, COND_None, REPNOTIFY_Always);
// 默认是 REPNOTIFY_OnChanged,只有值变化才触发
DOREPLIFETIME_CONDITION_NOTIFY(AMyCharacter, Health, COND_None, REPNOTIFY_OnChanged);
4.4 复制条件(谁能看到这个属性?)
4.4.1 问题场景
你的角色有 300发子弹(弹药数)。
- 你自己:需要看到子弹数,不然不知道还剩多少
- 其他玩家:不需要看到你的子弹数,浪费带宽
这就是复制条件的作用。
4.4.2 常用的复制条件
| 条件 | 效果 | 使用场景 |
|---|---|---|
| COND_None | 复制给所有人(默认) | 血量、位置、名字 |
| COND_OwnerOnly | 只复制给Owner | 弹药数、背包内容、私人数据 |
| COND_SkipOwner | 复制给除了Owner以外的所有人 | 其他玩家头顶的血条(你自己不需要看自己的血条?其实也需要,看设计) |
| COND_SimulatedOnly | 只复制给模拟端(其他玩家的角色) | 动画参数、移动轨迹 |
| COND_AutonomousOnly | 只复制给主控端(你自己控制的角色) | 准星偏移、后坐力 |
| COND_InitialOnly | 只在Actor生成时复制一次 | 静态数据(比如角色类型) |
| COND_Custom | 自定义条件 | 复杂规则 |
4.4.3 代码示例
cpp
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 1. 血量:复制给所有人(别人也要看你剩多少血)
DOREPLIFETIME_CONDITION(AMyCharacter, Health, COND_None);
// 2. 弹药:只复制给自己(别人不需要知道你剩多少子弹)
DOREPLIFETIME_CONDITION(AMyCharacter, Ammo, COND_OwnerOnly);
// 3. 当前武器:复制给模拟端(其他玩家需要知道你拿的什么武器,以便播放对应动画)
DOREPLIFETIME_CONDITION(AMyCharacter, CurrentWeaponType, COND_SimulatedOnly);
// 4. 镜头晃动:只复制给主控端(只有你自己需要知道镜头在晃)
DOREPLIFETIME_CONDITION(AMyCharacter, CameraShakeIntensity, COND_AutonomousOnly);
// 5. 角色职业:只在生成时复制一次(之后不会变)
DOREPLIFETIME_CONDITION(AMyCharacter, CharacterClass, COND_InitialOnly);
}
4.4.4 条件复制的工作原理图
🏢 服务器
属性: CurrentWeaponType (COND_SimulatedOnly)
Autonomous
Simulated
Simulated
服务器判断: 收件人角色类型?
❌ Client A (Autonomous) 不发送
✅ Client B (Simulated) 发送
✅ Client C (Simulated) 发送
属性: Health (COND_None)
服务器判断: 无条件
✅ 发送给 Client A
✅ 发送给 Client B
✅ 发送给 Client C
属性: Ammo (COND_OwnerOnly)
是
否
否
服务器判断: Owner是玩家A?
✅ 发送给 Client A
❌ 不发送给 Client B
❌ 不发送给 Client C
Owner = 玩家A
4.5 属性复制的底层原理
4.5.1 复制的Shadow Buffer
UE5为了知道"哪个属性变化了",为每个属性存了两份值:
cpp
// 简化版原理
class FReplicationManager
{
// 当前值(游戏逻辑正在用的)
float CurrentHealth;
// 影子值(上次发送给客户端的值)
float ShadowHealth;
void ReplicateProperties()
{
// 对比两个值
if (CurrentHealth != ShadowHealth)
{
// 有变化,需要发送
SendPropertyUpdate("Health", CurrentHealth);
// 发送后,把影子值更新为当前值
ShadowHealth = CurrentHealth;
}
}
};
每帧:UE5对比当前值和影子值 → 有差异就打包发送 → 发送后更新影子值。
4.5.2 增量同步
只说变化的部分,不说全部。
text
第1次发送:Health = 100(完整值)
第2次发送:Health = 80 (只发变化,不发其他属性)
第3次发送:Health = 50 (只发变化)
优势:节省带宽。
4.5.3 新玩家加入
新客户端连接时,UE5会强制发送所有属性的当前值(不管有没有变化)。
cpp
// 新客户端连上来
客户端发送:Gimme all current states!
服务器回复:Health=100, Ammo=30, Position=(100,200,50), ...
所以不需要你手动处理"新玩家加入时的状态同步"。
4.6 属性复制的性能优化
4.6.1 常见性能问题
| 问题 | 原因 | 后果 |
|---|---|---|
| 更新太频繁 | NetUpdateFrequency太高 | 带宽爆炸 |
| 同步太多属性 | 所有属性都在复制 | 每个包太大 |
| 同步给太多人 | 复制条件是COND_None | 广播浪费 |
| 使用大类型 | 同步FVector(12字节)而不是FVector_NetQuantize(压缩后更小) | 带宽浪费 |
4.6.2 优化技巧
降低更新频率
cpp
AMyUnimportantActor::AMyUnimportantActor()
{
// 重要角色:每秒更新30次
NetUpdateFrequency = 30.0f;
// 不重要角色:每秒更新2次(地上的金币、远处的装饰)
NetUpdateFrequency = 2.0f;
// 最低更新频率(防止完全不更新)
MinNetUpdateFrequency = 0.5f;
}
使用更小的数据类型
cpp
// ❌ 浪费
UPROPERTY(Replicated)
int32 Score; // 4字节
// ✅ 省带宽
UPROPERTY(Replicated)
uint16 Score; // 2字节
// ✅✅ 更省
UPROPERTY(Replicated)
uint8 Score; // 1字节(255分以内)
压缩位置数据
cpp
// ❌ 浪费:FVector 是 3个float = 12字节
UPROPERTY(Replicated)
FVector Position;
// ✅ 省带宽:FVector_NetQuantize 会自动压缩精度
UPROPERTY(Replicated)
FVector_NetQuantize Position; // 根据范围自动选择字节数
// 其他压缩类型:
// FVector_NetQuantize10:精度 0.01
// FVector_NetQuantize100:精度 0.001
// FVector_NetQuantizeNormal:单位向量(压缩到3字节)
使用复制条件减少接收者
cpp
// 默认:所有人都收
DOREPLIFETIME(AMyCharacter, Health);
// 优化:只给需要的人
DOREPLIFETIME_CONDITION(AMyCharacter, PrivateData, COND_OwnerOnly);
批量更新代替频繁更新
cpp
// ❌ 坏:每帧可能多次修改
void Tick(float DeltaTime)
{
if (SomeCondition)
{
Score += 10; // 每次修改都会触发同步(如果值变化了)
}
}
// ✅ 好:累积后一次更新
void Tick(float DeltaTime)
{
PendingScore += 10;
if (ShouldCommitScore()) // 比如每秒提交一次
{
Score += PendingScore;
PendingScore = 0;
}
}
4.7 完整示例:一个带属性复制的角色
cpp
// MyCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"
UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AMyCharacter();
protected:
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// ========== 可复制的属性 ==========
// 血量(所有人可见,变化时触发OnRep)
UPROPERTY(ReplicatedUsing = OnRep_Health, BlueprintReadOnly)
float Health = 100.0f;
// 弹药(只有主人可见)
UPROPERTY(Replicated, BlueprintReadOnly)
int32 Ammo = 30;
// 分数(所有人可见)
UPROPERTY(Replicated, BlueprintReadOnly)
int32 Score = 0;
// 当前武器类型(只复制给模拟端,用于动画)
UPROPERTY(Replicated)
int32 CurrentWeaponType = 0;
// 名字(只在生成时复制一次)
UPROPERTY(Replicated)
FString PlayerName;
// ========== 函数 ==========
// 受到伤害(只能在服务器调用)
void TakeDamage(float DamageAmount);
// 增加分数(只能在服务器调用)
void AddScore(int32 Amount);
// 减少弹药(只能在服务器调用)
void ConsumeAmmo();
// OnRep回调
UFUNCTION()
void OnRep_Health();
private:
// UI更新(在客户端执行)
void UpdateHealthUI();
void UpdateAmmoUI();
void UpdateScoreUI();
};
cpp
// MyCharacter.cpp
#include "MyCharacter.h"
#include "Net/UnrealNetwork.h"
AMyCharacter::AMyCharacter()
{
// 开启复制
bReplicates = true;
}
void AMyCharacter::BeginPlay()
{
Super::BeginPlay();
// 如果是客户端,初始化UI
if (IsClient())
{
UpdateHealthUI();
UpdateAmmoUI();
UpdateScoreUI();
}
}
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 注册所有需要复制的属性
DOREPLIFETIME_CONDITION(AMyCharacter, Health, COND_None);
DOREPLIFETIME_CONDITION(AMyCharacter, Ammo, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(AMyCharacter, Score, COND_None);
DOREPLIFETIME_CONDITION(AMyCharacter, CurrentWeaponType, COND_SimulatedOnly);
DOREPLIFETIME_CONDITION(AMyCharacter, PlayerName, COND_InitialOnly);
}
void AMyCharacter::TakeDamage(float DamageAmount)
{
// 只在服务器执行
if (!HasAuthority()) return;
// 计算新血量
float NewHealth = FMath::Max(0.0f, Health - DamageAmount);
// 修改属性(会自动触发同步)
Health = NewHealth;
// 血量归零,死亡
if (Health <= 0.0f)
{
Die();
}
// 注意:不需要手动发送任何网络消息!
// Health的更新会自动同步到所有客户端
}
void AMyCharacter::AddScore(int32 Amount)
{
if (!HasAuthority()) return;
Score += Amount;
// Score会自动同步到所有客户端
}
void AMyCharacter::ConsumeAmmo()
{
if (!HasAuthority()) return;
Ammo = FMath::Max(0, Ammo - 1);
// 只有拥有这个武器的玩家会收到Ammo的更新
}
void AMyCharacter::OnRep_Health()
{
// 这个函数只在客户端执行
// 当Health从服务器同步过来时调用
UE_LOG(LogTemp, Log, TEXT("Health updated to %f"), Health);
// 更新UI
UpdateHealthUI();
// 播放受伤特效
if (Health < 100.0f)
{
PlayHitEffect();
}
// 检查死亡
if (Health <= 0.0f)
{
PlayDeathAnimation();
}
}
void AMyCharacter::UpdateHealthUI()
{
// 更新血条显示
// 实际项目中会找到UI Widget并更新
UE_LOG(LogTemp, Log, TEXT("UI: Health = %f"), Health);
}
void AMyCharacter::UpdateAmmoUI()
{
// 只有自己能看到弹药数
UE_LOG(LogTemp, Log, TEXT("UI: Ammo = %d"), Ammo);
}
void AMyCharacter::UpdateScoreUI()
{
UE_LOG(LogTemp, Log, TEXT("UI: Score = %d"), Score);
}
五、核心同步机制:RPC
5.1 什么是RPC(远端调用)?------ 像调用本地函数一样调用远程函数
5.1.1 问题场景
你已经学会了属性复制(自动同步"状态"),但有些事不是"状态",而是"事件":
- 玩家开了一枪(瞬时动作,不是持续状态)
- 玩家打开了一扇门(事件发生后,门的状态变了,但开门这个动作本身是事件)
- 播放一个爆炸特效(一次性效果,不需要持久化)
这些事如果用属性复制来做:
cpp
// ❌ 用属性复制做事件,很别扭
UPROPERTY(ReplicatedUsing = OnRep_bShouldFire)
bool bShouldFire = false;
void Fire()
{
bShouldFire = true; // 改成true
// 然后OnRep里执行开火
// 然后再改回false?很麻烦
}
更好的办法:RPC
RPC让你可以直接调用另一台电脑上的函数。
cpp
// ✅ 用RPC做事件
UFUNCTION(Server, Reliable)
void ServerFire(); // 客户端调用这个函数,它会在服务器上执行
// 使用
void Fire()
{
ServerFire(); // 就像调用本地函数一样!
}
5.1.2 RPC的通俗理解
普通函数:你在你的电脑上调用,在你的电脑上执行。
RPC :你在你的电脑上调用,在另一台电脑上执行。
🏢 服务器 💻 你的电脑 (客户端) 🏢 服务器 💻 你的电脑 (客户端) 调用 ClientDoSomething() ServerDoSomething_Implementation() (实际执行) RPC通常没有返回值 RPC调用
5.1.3 RPC vs 属性复制:什么时候用哪个?
| 场景 | 用什么 | 原因 |
|---|---|---|
| 角色血量 | 属性复制 | 这是状态,需要持久化,新玩家要知道当前血量 |
| 角色位置 | 属性复制 | 持续变化的状态 |
| 玩家开火 | RPC | 瞬时事件,不需要保存"是否开过火" |
| 玩家捡起道具 | RPC | 事件,结果是道具消失、玩家获得道具(这两个是状态) |
| 播放特效 | RPC(Unreliable) | 丢了也没关系 |
| 聊天消息 | RPC(Reliable) | 必须到达 |
简单判断:
-
问自己:这个数据需要"保存"吗?新加入的玩家需要知道吗?
- 是 → 属性复制
- 否 → RPC
5.2 三种RPC类型
UE5有三种RPC,区别在于谁调用、谁执行。
| 类型 | 谁调用 | 谁执行 | 用途 |
|---|---|---|---|
| Server RPC | 客户端 | 服务器 | 客户端请求服务器做某事(开火、移动、捡东西) |
| Client RPC | 服务器 | 某个客户端 | 服务器告诉某个客户端私密信息(扣血数字、UI更新) |
| NetMulticast RPC | 服务器 | 所有客户端 | 服务器广播效果(爆炸、声音、全局通知) |
5.2.1 Server RPC(客户端 → 服务器)
最常见的一种RPC。客户端调用,服务器执行。
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 声明Server RPC
UFUNCTION(Server, Reliable)
void ServerFire();
// 玩家按鼠标时
void Fire()
{
if (HasAuthority())
{
// 如果已经在服务器,直接执行
ActuallyFire();
}
else
{
// 如果是客户端,发RPC给服务器
ServerFire();
}
}
// 必须实现 _Implementation 版本
void ServerFire_Implementation()
{
// 这段代码在服务器执行
ActuallyFire();
}
private:
void ActuallyFire()
{
// 生成子弹、减少弹药、播放特效等
}
};
流程图:
🏢 服务器 💻 客户端A 🏢 服务器 💻 客户端A 玩家按左键 ServerFire_Implementation() 执行 ActuallyFire() 生成子弹、扣弹药 血量变化 (100→80) 血量更新,显示血条变化 | ServerFire() RPC调用 属性复制同步血量
5.2.2 Client RPC(服务器 → 特定客户端)
服务器调用,指定的那个客户端执行。
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 声明Client RPC
UFUNCTION(Client, Reliable)
void ClientShowDamage(float Damage, FVector HitLocation);
// 服务器调用这个函数
void TakeDamage(float Damage, AController* InstigatedBy)
{
if (!HasAuthority()) return;
Health -= Damage;
// 只告诉被打的玩家,显示伤害数字
APlayerController* VictimPC = GetController<APlayerController>();
if (VictimPC)
{
ClientShowDamage(Damage, GetActorLocation());
}
}
// 实现
void ClientShowDamage_Implementation(float Damage, FVector HitLocation)
{
// 这段代码在被打的客户端执行
ShowDamageNumber(Damage, HitLocation);
}
private:
void ShowDamageNumber(float Damage, FVector Location)
{
// 在屏幕上飘一个"-50"的数字
}
};
流程图:
💻 客户端B (其他玩家) 💻 客户端A (被打的玩家) 🏢 服务器 💻 客户端B (其他玩家) 💻 客户端A (被打的玩家) 🏢 服务器 玩家A受到50点伤害 ClientShowDamage_Implementation() 显示 "-50" 伤害数字 其他客户端不会收到这个RPC ✅ 只有被打的玩家看到伤害数字 ClientShowDamage(50) RPC (不发送)
注意 :Client RPC只发给一个客户端,不是所有。如果想发给所有人,用NetMulticast。
5.2.3 NetMulticast RPC(服务器 → 所有客户端)
服务器调用,所有客户端都执行。
cpp
UCLASS()
class AMyGrenade : public AActor
{
GENERATED_BODY()
public:
// 声明Multicast RPC
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayExplosionEffect();
void Explode()
{
if (!HasAuthority()) return;
// 造成伤害...
DealDamage();
// 告诉所有客户端播放爆炸特效
MulticastPlayExplosionEffect();
// 销毁手雷
Destroy();
}
void MulticastPlayExplosionEffect_Implementation()
{
// 这段代码在所有客户端执行
// 播放粒子特效、声音
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ExplosionEffect, GetActorLocation());
UGameplayStatics::PlaySoundAtLocation(GetWorld(), ExplosionSound, GetActorLocation());
}
private:
UPROPERTY(EditDefaultsOnly)
UParticleSystem* ExplosionEffect;
UPROPERTY(EditDefaultsOnly)
USoundBase* ExplosionSound;
};
流程图:
💻 客户端C 💻 客户端B 💻 客户端A 🏢 服务器 💻 客户端C 💻 客户端B 💻 客户端A 🏢 服务器 手雷爆炸 播放爆炸特效 播放爆炸特效 播放爆炸特效 (不需要等待回复) MulticastPlayExplosionEffect() MulticastPlayExplosionEffect() MulticastPlayExplosionEffect()
5.3 Reliable vs Unreliable
5.3.1 区别
| 关键字 | 保证到达 | 会重传 | 顺序保证 | 性能 |
|---|---|---|---|---|
| Reliable | ✅ 是 | ✅ 会 | ✅ 保证 | 较慢,可能阻塞 |
| Unreliable | ❌ 否 | ❌ 不会 | ❌ 不保证 | 快 |
5.3.2 什么时候用什么?
cpp
// 关键逻辑:用 Reliable
UFUNCTION(Server, Reliable)
void ServerTakeDamage(float Damage); // 扣血必须到
UFUNCTION(Server, Reliable)
void ServerPickupItem(); // 捡道具必须到
// 高频数据:用 Unreliable
UFUNCTION(Server, Unreliable)
void ServerSendInput(float Forward, float Right); // 移动输入每秒60次,丢几个没关系
// 表现效果:用 Unreliable
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayFootstepSound(); // 脚步声丢了就丢了
5.3.3 Reliable的陷阱:通道阻塞
cpp
// ❌ 错误示例:高频RPC用Reliable
void Tick(float DeltaTime)
{
// 每秒60次调用Reliable RPC
ServerSendInput(Forward, Right); // Reliable!
}
后果:网络稍微不好,这些Reliable RPC会堆积,阻塞后续所有网络数据。玩家会感觉越来越卡。
正确做法:
cpp
// ✅ 正确:高频用Unreliable
UFUNCTION(Server, Unreliable)
void ServerSendInput(float Forward, float Right);
// ✅ 正确:关键事件用Reliable
UFUNCTION(Server, Reliable)
void ServerFire(); // 开火频率低(比如每秒最多2次)
5.4 RPC的验证(防止作弊)
5.4.1 问题
客户端可以调用Server RPC。如果客户端是作弊的,它可以:
- 调用
ServerTeleport(敌人位置, 直接获胜) - 调用
ServerAddScore(999999) - 调用
ServerHealSelf(1000)
所以必须验证!
5.4.2 WithValidation
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 加上 WithValidation
UFUNCTION(Server, Reliable, WithValidation)
void ServerTeleport(FVector TargetLocation);
// 验证函数:检查参数是否合法
bool ServerTeleport_Validate(FVector TargetLocation)
{
// 1. 检查传送距离不能太远
float Distance = FVector::Dist(TargetLocation, GetActorLocation());
if (Distance > 5000.0f) // 超过5000单位,不可能
{
UE_LOG(LogTemp, Warning, TEXT("作弊检测:传送距离 %f 超过限制"), Distance);
return false; // 验证失败,RPC不会执行
}
// 2. 检查目标位置是否在地图内
if (TargetLocation.Z < -1000 || TargetLocation.Z > 10000)
{
return false;
}
// 3. 检查是否在碰撞体内(不能传进墙里)
if (IsPointInsideWall(TargetLocation))
{
return false;
}
return true; // 验证通过
}
// 实现函数:验证通过后执行
void ServerTeleport_Implementation(FVector TargetLocation)
{
SetActorLocation(TargetLocation);
}
private:
bool IsPointInsideWall(FVector Point)
{
// 检查点是否在墙内...
return false;
}
};
重要 :_Validate 函数在服务器 执行,检查参数合法性。返回 false 时,RPC不会执行 _Implementation。
5.4.3 常见验证规则
cpp
// 1. 数值范围验证
bool ServerSetScore_Validate(int32 NewScore)
{
// 一次最多加100分
return (NewScore - CurrentScore) <= 100;
}
// 2. 冷却时间验证
bool ServerFire_Validate()
{
float CurrentTime = GetWorld()->GetTimeSeconds();
if (CurrentTime - LastFireTime < FireCooldown)
{
return false; // 开火太快了
}
LastFireTime = CurrentTime;
return true;
}
// 3. 空间验证
bool ServerPickupItem_Validate(APickupItem* Item)
{
if (!Item) return false;
// 检查物品和玩家的距离
float Distance = GetDistanceTo(Item);
return Distance < PickupRange; // 必须在拾取范围内
}
// 4. 状态验证
bool ServerUseAbility_Validate(int32 AbilityIndex)
{
// 检查技能是否可用(冷却、等级等)
return IsAbilityAvailable(AbilityIndex);
}
5.5 RPC的参数类型限制
RPC的参数会被打包通过网络发送,所以有限制。
✅ 可以用的类型:
cpp
// 基础类型
UFUNCTION(Server, Reliable)
void ServerTest1(int32 Value); // int
UFUNCTION(Server, Reliable)
void ServerTest2(float Value); // float
UFUNCTION(Server, Reliable)
void ServerTest3(bool Value); // bool
UFUNCTION(Server, Reliable)
void ServerTest4(FString Value); // FString
UFUNCTION(Server, Reliable)
void ServerTest5(FName Value); // FName
UFUNCTION(Server, Reliable)
void ServerTest6(FVector Value); // FVector
UFUNCTION(Server, Reliable)
void ServerTest7(FRotator Value); // FRotator
// 对象引用(UE会帮你处理引用关系)
UFUNCTION(Server, Reliable)
void ServerTest8(AActor* Target); // Actor指针
UFUNCTION(Server, Reliable)
void ServerTest9(UPrimitiveComponent* Comp); // Component指针
// 数组
UFUNCTION(Server, Reliable)
void ServerTest10(const TArray<int32>& Values); // TArray
❌ 不能用的类型:
cpp
// 不能传临时对象(地址会失效)
UFUNCTION(Server, Reliable)
void ServerTest(FMyLocalStruct* Struct); // ❌ 普通指针不行
// 不能传大的内容(性能差)
UFUNCTION(Server, Reliable)
void ServerTest(const TArray<uint8>& HugeData); // 尽量避免
// 不能传Lambda或函数指针
注意:当传递Actor或Component指针时,UE会自动把它们转成网络ID(NetGUID),在接收端还原成指针。所以可以放心传。
5.6 RPC命名规则
UE的RPC有严格的命名规则:
cpp
// 假设你声明了 ServerDoSomething
// 1. 必须实现 _Implementation
void ServerDoSomething_Implementation()
{
// 实际逻辑
}
// 2. 如果有 Validation,必须实现 _Validate
bool ServerDoSomething_Validate()
{
return true;
}
// 3. 不能自己调用 _Implementation 或 _Validate
// 错误:ServerDoSomething_Implementation(); // 不要这样做
// 正确:ServerDoSomething(); // 调用声明时的名字
完整示例:
cpp
// 声明
UFUNCTION(Server, Reliable, WithValidation)
void ServerPickupItem(AActor* Item);
// 验证
bool ServerPickupItem_Validate(AActor* Item)
{
return IsValid(Item) && GetDistanceTo(Item) < 100.0f;
}
// 实现
void ServerPickupItem_Implementation(AActor* Item)
{
Item->Destroy();
Score += 10;
}
// 调用(在客户端)
void PickupItem(AActor* Item)
{
ServerPickupItem(Item); // 调用声明时的名字,不是_Implementation
}
5.7 实际项目中的RPC设计模式
模式1:请求-确认模式
cpp
// 客户端请求,服务器确认
UFUNCTION(Server, Reliable)
void ServerRequestOpenDoor(ADoor* Door);
// 服务器执行
void ServerRequestOpenDoor_Implementation(ADoor* Door)
{
if (Door && Door->CanOpen(GetCharacter()))
{
Door->Open(); // 开门,Door的bIsOpen属性会通过属性复制同步
}
}
模式2:广播效果模式
cpp
// 服务器广播效果给所有人
void Explode()
{
// 造成伤害...
// 广播爆炸特效
MulticastPlayExplosion();
}
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayExplosion()
{
// 所有客户端播放特效
}
模式3:私密信息模式
cpp
// 服务器只告诉特定客户端
void OnPlayerDamaged(APlayerController* DamagedPlayer, float Damage)
{
// 只告诉被打的玩家
DamagedPlayer->ClientShowDamage(Damage);
}
UFUNCTION(Client, Reliable)
void ClientShowDamage(float Damage)
{
// 只在这个玩家的客户端显示
ShowDamageNumber(Damage);
}
模式4:验证模式(防作弊)
cpp
// 带验证的Server RPC
UFUNCTION(Server, Reliable, WithValidation)
void ServerFire(FVector_NetQuantize HitLocation);
bool ServerFire_Validate(FVector_NetQuantize HitLocation)
{
// 检查射击距离、角度、冷却等
float Distance = FVector::Dist(HitLocation, GetActorLocation());
if (Distance > WeaponRange) return false;
float CurrentTime = GetWorld()->GetTimeSeconds();
if (CurrentTime - LastFireTime < FireCooldown) return false;
LastFireTime = CurrentTime;
return true;
}
void ServerFire_Implementation(FVector_NetQuantize HitLocation)
{
// 验证通过,执行真正的射击逻辑
ActuallyFire(HitLocation);
}
5.8 RPC与属性复制的配合
实际项目中,RPC和属性复制经常一起用:
cpp
// 场景:玩家开火
// 1. 客户端调用 ServerFire(RPC)
// 2. 服务器减少弹药(属性复制)
// 3. 服务器广播特效(RPC)
void AMyCharacter::Fire()
{
if (HasAuthority())
{
ActuallyFire();
}
else
{
ServerFire(); // RPC: 客户端 → 服务器
}
}
void AMyCharacter::ServerFire_Implementation()
{
ActuallyFire();
}
void AMyCharacter::ActuallyFire()
{
if (Ammo <= 0) return; // Ammo是属性复制,服务器有权威值
Ammo--; // 修改属性复制,客户端会自动更新
// 生成子弹(在服务器生成,子弹会自动复制给客户端)
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = this;
GetWorld()->SpawnActor<ABullet>(BulletClass, GetActorLocation(), GetActorRotation(), SpawnParams);
// 广播开枪特效(RPC)
MulticastPlayMuzzleFlash(); // NetMulticast RPC
}
UFUNCTION(NetMulticast, Unreliable)
void AMyCharacter::MulticastPlayMuzzleFlash()
{
// 所有客户端播放枪口火焰特效
}
分工:
- RPC:传递"开火"这个事件
- 属性复制:同步"弹药减少"这个状态变化
- RPC:广播"特效"这个一次性效果
六、深入底层:UActorChannel 与数据流转
6.1 为什么需要了解底层?
虽然你平时不直接操作这些类,但理解了底层,你就能:
- 诊断网络问题(为什么这个Actor没复制?)
- 优化性能(什么时候带宽会爆炸?)
- 理解引擎的设计哲学
6.2 完整的数据流转过程
6.2.1 发送端(服务器)
text
你的Actor(比如ACharacter)
↓
【FRepState】Shadow buffer(存着上次发送的值)
↓
【FRepLayout】对比当前值 vs 影子值,找出变化
↓
【FNetBitWriter】把变化打包成二进制数据
↓
【UActorChannel】为每个Actor分配一个通道
↓
【UNetConnection】为每个客户端分配一个连接
↓
【FBitWriter】最终的数据包
↓
【UDP Socket】发送出去
6.2.2 接收端(客户端)
text
【UDP Socket】收到数据
↓
【UNetConnection】找到对应的连接
↓
【UActorChannel】找到对应的Actor通道
↓
【FNetBitReader】读取二进制数据
↓
【FRepLayout】解析数据,找到哪些属性变化了
↓
【Actor】更新属性值
↓
触发【OnRep】函数
6.3 UActorChannel:每个Actor一条专用通道
什么是 ActorChannel?
每个网络相关的Actor,在服务器和每个客户端之间都有一条专属的数据通道。
text
服务器 客户端A
┌──────────────────┐ ┌──────────────────┐
│ Actor: 玩家A │ ←→ │ Channel 1 │ ← 玩家A的通道
├──────────────────┤ ├──────────────────┤
│ Actor: 玩家B │ ←→ │ Channel 2 │ ← 玩家B的通道
├──────────────────┤ ├──────────────────┤
│ Actor: 宝箱 │ ←→ │ Channel 3 │ ← 宝箱的通道
└──────────────────┘ └──────────────────┘
通道的生命周期:
text
1. Actor 被创建(服务器)
2. 服务器检测到这个Actor对客户端"相关"
3. 打开 ActorChannel(发送"创建Actor"指令)
4. 客户端收到,创建本地Actor
5. 每帧通过这个通道同步属性变化
6. Actor 不再相关(距离太远/被销毁)
7. 关闭 ActorChannel
8. 客户端销毁本地Actor
6.4 Bunch:数据包的逻辑单元
什么是 Bunch?
一次网络传输中,一个Actor的数据叫做一个Bunch。
📦 网络包 (Packet)
┌─────────────┐
│ Bunch for │
│ 玩家A │
│ (位置变化) │
└─────────────┘
┌─────────────┐
│ Bunch for │
│ 玩家B │
│ (血量变化) │
└─────────────┘
┌─────────────┐
│ Bunch for │
│ 宝箱 │
│ (状态变化) │
└─────────────┘
从Channel接受数据的Bunch:

发送接受数据的Bunch:

6.5 NetDriver的复制循环
UE5每帧在服务器上执行 ServerReplicateActors():

cpp
int32 UNetDriver::ServerReplicateActors(float DeltaSeconds)
{
SCOPE_CYCLE_COUNTER(STAT_NetServerRepActorsTime);
#if WITH_SERVER_CODE
if ( ClientConnections.Num() == 0 )
{
return 0;
}
GetMetrics()->SetInt(UE::Net::Metric::NumReplicatedActors,0 );
GetMetrics()->SetInt(UE::Net::Metric::NumReplicatedActorBytes, 0);
#if CSV_PROFILER_STATS
FScopedNetDriverStats NetDriverStats(this);
GNumClientConnections = ClientConnections.Num();
#endif
if (ReplicationDriver)
{
return ReplicationDriver->ServerReplicateActors(DeltaSeconds);
}
check( World );
// Bump the ReplicationFrame value to invalidate any properties marked as "unchanged" for this frame.
ReplicationFrame++;
int32 Updated = 0;
const int32 NumClientsToTick = ServerReplicateActors_PrepConnections( DeltaSeconds );
if ( NumClientsToTick == 0 )
{
// No connections are ready this frame
return 0;
}
AWorldSettings* WorldSettings = World->GetWorldSettings();
bool bCPUSaturated = false;
float ServerTickTime = GEngine->GetMaxTickRate( DeltaSeconds );
if ( ServerTickTime == 0.f )
{
ServerTickTime = DeltaSeconds;
}
else
{
ServerTickTime = 1.f/ServerTickTime;
bCPUSaturated = DeltaSeconds > 1.2f * ServerTickTime;
}
TArray<FNetworkObjectInfo*> ConsiderList;
ConsiderList.Reserve( GetNetworkObjectList().GetActiveObjects().Num() );
// Build the consider list (actors that are ready to replicate)
ServerReplicateActors_BuildConsiderList( ConsiderList, ServerTickTime );
TSet<UNetConnection*> ConnectionsToClose;
FMemMark Mark( FMemStack::Get() );
if (OnPreConsiderListUpdateOverride.IsBound())
{
OnPreConsiderListUpdateOverride.Execute({ DeltaSeconds, nullptr, bCPUSaturated }, Updated, ConsiderList);
}
for ( int32 i=0; i < ClientConnections.Num(); i++ )
{
UNetConnection* Connection = ClientConnections[i];
check(Connection);
// net.DormancyValidate can be set to 2 to validate all dormant actors against last known state before going dormant
if ( GNetDormancyValidate == 2 )
{
auto ValidateFunction = [](FObjectKey OwnerActorKey, FObjectKey ObjectKey, const TSharedRef<FObjectReplicator>& ReplicatorRef)
{
FObjectReplicator& Replicator = ReplicatorRef.Get();
// We will call FObjectReplicator::ValidateAgainstState multiple times for
// the same object (once for itself and again for each subobject).
if (Replicator.OwningChannel != nullptr)
{
Replicator.ValidateAgainstState(Replicator.OwningChannel->GetActor());
}
};
Connection->ExecuteOnAllDormantReplicators(ValidateFunction);
}
// if this client shouldn't be ticked this frame
if (i >= NumClientsToTick)
{
//UE_LOG(LogNet, Log, TEXT("skipping update to %s"),*Connection->GetName());
// then mark each considered actor as bPendingNetUpdate so that they will be considered again the next frame when the connection is actually ticked
for (int32 ConsiderIdx = 0; ConsiderIdx < ConsiderList.Num(); ConsiderIdx++)
{
AActor *Actor = ConsiderList[ConsiderIdx]->Actor;
// if the actor hasn't already been flagged by another connection,
if (Actor != NULL && !ConsiderList[ConsiderIdx]->bPendingNetUpdate)
{
// find the channel
UActorChannel *Channel = Connection->FindActorChannelRef(ConsiderList[ConsiderIdx]->WeakActor);
// and if the channel last update time doesn't match the last net update time for the actor
if (Channel != NULL && Channel->LastUpdateTime < ConsiderList[ConsiderIdx]->LastNetUpdateTimestamp)
{
//UE_LOG(LogNet, Log, TEXT("flagging %s for a future update"),*Actor->GetName());
// flag it for a pending update
ConsiderList[ConsiderIdx]->bPendingNetUpdate = true;
}
}
}
// clear the time sensitive flag to avoid sending an extra packet to this connection
Connection->TimeSensitive = false;
}
else if (Connection->ViewTarget)
{
const int32 LocalNumSaturated = GNumSaturatedConnections;
// Make a list of viewers this connection should consider (this connection and children of this connection)
TArray<FNetViewer>& ConnectionViewers = WorldSettings->ReplicationViewers;
ConnectionViewers.Reset();
new( ConnectionViewers )FNetViewer( Connection, DeltaSeconds );
for ( int32 ViewerIndex = 0; ViewerIndex < Connection->Children.Num(); ViewerIndex++ )
{
if ( Connection->Children[ViewerIndex]->ViewTarget != NULL )
{
new( ConnectionViewers )FNetViewer( Connection->Children[ViewerIndex], DeltaSeconds );
}
}
// send ClientAdjustment if necessary
// we do this here so that we send a maximum of one per packet to that client; there is no value in stacking additional corrections
if ( Connection->PlayerController )
{
Connection->PlayerController->SendClientAdjustment();
}
for ( int32 ChildIdx = 0; ChildIdx < Connection->Children.Num(); ChildIdx++ )
{
if ( Connection->Children[ChildIdx]->PlayerController != NULL )
{
Connection->Children[ChildIdx]->PlayerController->SendClientAdjustment();
}
}
FMemMark RelevantActorMark(FMemStack::Get());
const bool bProcessConsiderListIsBound = OnProcessConsiderListOverride.IsBound();
if (bProcessConsiderListIsBound)
{
OnProcessConsiderListOverride.Execute( { DeltaSeconds, Connection, bCPUSaturated }, Updated, ConsiderList );
}
if (!bProcessConsiderListIsBound)
{
FActorPriority* PriorityList = NULL;
FActorPriority** PriorityActors = NULL;
// Get a sorted list of actors for this connection
const int32 FinalSortedCount = ServerReplicateActors_PrioritizeActors(Connection, ConnectionViewers, ConsiderList, bCPUSaturated, PriorityList, PriorityActors);
// Process the sorted list of actors for this connection
TInterval<int32> ActorsIndexRange(0, FinalSortedCount);
const int32 LastProcessedActor = ServerReplicateActors_ProcessPrioritizedActorsRange(Connection, ConnectionViewers, PriorityActors, ActorsIndexRange, Updated);
ServerReplicateActors_MarkRelevantActors(Connection, ConnectionViewers, LastProcessedActor, FinalSortedCount, PriorityActors);
}
RelevantActorMark.Pop();
ConnectionViewers.Reset();
Connection->LastProcessedFrame = ReplicationFrame;
const bool bWasSaturated = GNumSaturatedConnections > LocalNumSaturated;
Connection->TrackReplicationForAnalytics(bWasSaturated);
}
if (Connection->GetPendingCloseDueToReplicationFailure())
{
ConnectionsToClose.Add(Connection);
}
}
if (OnPostConsiderListUpdateOverride.IsBound())
{
OnPostConsiderListUpdateOverride.ExecuteIfBound( { DeltaSeconds, nullptr, bCPUSaturated }, Updated, ConsiderList );
}
// shuffle the list of connections if not all connections were ticked
if (NumClientsToTick < ClientConnections.Num())
{
int32 NumConnectionsToMove = NumClientsToTick;
while (NumConnectionsToMove > 0)
{
// move all the ticked connections to the end of the list so that the other connections are considered first for the next frame
UNetConnection *Connection = ClientConnections[0];
ClientConnections.RemoveAt(0,1);
ClientConnections.Add(Connection);
NumConnectionsToMove--;
}
}
Mark.Pop();
#if NET_DEBUG_RELEVANT_ACTORS
if (DebugRelevantActors)
{
PrintDebugRelevantActors();
LastPrioritizedActors.Empty();
LastSentActors.Empty();
LastRelevantActors.Empty();
LastNonRelevantActors.Empty();
DebugRelevantActors = false;
}
#endif // NET_DEBUG_RELEVANT_ACTORS
for (UNetConnection* ConnectionToClose : ConnectionsToClose)
{
ConnectionToClose->Close();
}
return Updated;
#else
return 0;
#endif // WITH_SERVER_CODE
}
七、相关性裁剪:Relevancy 系统详解
7.1 为什么需要Relevancy?
问题:一个服务器上有1000个Actor,但每个客户端只需要知道其中的一小部分。
- 你在A点,远处的B点有个宝箱 → 你不需要知道
- 地图另一端的玩家在做什么 → 你不需要知道
- 和你同一个副本的玩家 → 你需要知道
Relevancy系统就是做这个的:过滤掉客户端不需要的Actor,节省带宽。
7.2 默认的相关性规则
UE5的 AActor::IsNetRelevantFor() 决定了Actor是否对某个客户端相关。
cpp
bool AActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
if (bAlwaysRelevant || IsOwnedBy(ViewTarget) || IsOwnedBy(RealViewer) || this == ViewTarget || ViewTarget == GetInstigator())
{
return true;
}
else if (bNetUseOwnerRelevancy && Owner)
{
return Owner->IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
else if (bOnlyRelevantToOwner)
{
return false;
}
else if (RootComponent && RootComponent->GetAttachParent() && RootComponent->GetAttachParent()->GetOwner() && (Cast<USkeletalMeshComponent>(RootComponent->GetAttachParent()) || (RootComponent->GetAttachParent()->GetOwner() == Owner)))
{
return RootComponent->GetAttachParent()->GetOwner()->IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
else if(IsHidden() && (!RootComponent || !RootComponent->IsCollisionEnabled()))
{
return false;
}
if (!RootComponent)
{
UE_LOG(LogNet, Warning, TEXT("Actor %s / %s has no root component in AActor::IsNetRelevantFor. (Make bAlwaysRelevant=true?)"), *GetClass()->GetName(), *GetName() );
return false;
}
return !GetDefault<AGameNetworkManager>()->bUseDistanceBasedRelevancy ||
IsWithinNetRelevancyDistance(SrcLocation);
}
7.3 配置相关性
7.3.1 距离裁剪
cpp
// 设置Actor的同步距离
AMyActor::AMyActor()
{
bReplicates = true;
// 设置同步距离为5000单位(50米)
NetCullDistanceSquared = 5000.0f * 5000.0f;
}
7.3.2 全局设置
在 DefaultEngine.ini 中:
ini
[/Script/OnlineSubsystemUtils.IpNetDriver]
; 启用距离裁剪
bUseDistanceBasedRelevancy=true
; 默认裁剪距离(单位:厘米)
NetCullDistance=15000.0
; 是否忽略距离检查(调试用)
bDisableRelevancyCheck=false
7.3.3 特定标记
cpp
// 总是相关(GameMode、GameState、PlayerState)
AMyGameState::AMyGameState()
{
bAlwaysRelevant = true;
}
// 只对Owner相关(武器、背包)
AMyWeapon::AMyWeapon()
{
bOnlyRelevantToOwner = true;
}
7.4 自定义相关性过滤
7.4.1 重写 IsNetRelevantFor
cpp
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
virtual bool IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const override
{
// 只复制给同队伍的玩家
APlayerController* ViewerPC = Cast<APlayerController>(RealViewer);
if (!ViewerPC) return false;
AMyPlayerState* ViewerPS = ViewerPC->GetPlayerState<AMyPlayerState>();
AMyPlayerState* MyPS = GetOwnerPlayerState();
if (ViewerPS && MyPS)
{
return ViewerPS->GetTeam() == MyPS->GetTeam();
}
return false;
}
};
7.5 超出范围后的恢复
当玩家跑出范围再跑回来:
text
1. 玩家距离宝箱 2000单位(在范围内)→ 客户端有宝箱
2. 玩家跑到 10000单位(超出范围)→ 服务器关闭通道,客户端销毁宝箱
3. 玩家跑回 2000单位(重新进入范围)→ 服务器打开新通道,重新同步宝箱的所有属性
注意 :重新同步时,客户端会重新创建 这个Actor,所有 Replicated 属性都会从服务器获取当前值。
八、玩家生命周期:登录、登出与断线重连
8.1 玩家加入的完整流程
🏢 服务器 💻 客户端 🏢 服务器 💻 客户端 1. 点击"连接服务器" 2. NetDriver接受连接 3. 创建PlayerController - 检查服务器满没满 - 检查版本是否匹配 - 检查是否被ban 6. 创建PlayerState 7. GameMode::PostLogin() - 加载玩家数据 - 生成Pawn - 通知其他玩家 9. 显示游戏画面 | ClientTravel("xxx.xxx.xxx") 4. GameMode::PreLogin() 5. 验证通过 8. 同步世界状态
8.2 PreLogin与PostLogin的实现
cpp
UCLASS()
class AMyGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
// PreLogin:验证是否可以加入
virtual void PreLogin(const FString& Options, const FString& Address,
const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage) override
{
// 1. 检查服务器是否满了
if (GetNumPlayers() >= MaxPlayers)
{
ErrorMessage = TEXT("Server is full");
return;
}
// 2. 检查版本
FString ClientVersion = UGameplayStatics::ParseOption(Options, "Version");
if (ClientVersion != ExpectedVersion)
{
ErrorMessage = TEXT("Wrong game version");
return;
}
// 3. 检查是否被ban
if (IsBanned(UniqueId))
{
ErrorMessage = TEXT("You are banned");
return;
}
// 一切正常,允许加入
Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
}
// PostLogin:玩家正式加入后的初始化
virtual void PostLogin(APlayerController* NewPlayer) override
{
Super::PostLogin(NewPlayer);
AMyPlayerState* PS = NewPlayer->GetPlayerState<AMyPlayerState>();
if (PS)
{
// 加载玩家数据
LoadPlayerData(PS);
// 告诉所有其他玩家:有人加入了
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
APlayerController* PC = It->Get();
if (PC && PC != NewPlayer)
{
PC->ClientNotifyPlayerJoined(PS->GetPlayerName());
}
}
}
// 生成角色
RestartPlayer(NewPlayer);
}
private:
int32 MaxPlayers = 10;
FString ExpectedVersion = "1.0.0";
bool IsBanned(const FUniqueNetIdRepl& UniqueId)
{
return BannedList.Contains(UniqueId);
}
TSet<FUniqueNetIdRepl> BannedList;
};
8.3 登出与清理
cpp
virtual void Logout(AController* Exiting) override
{
APlayerController* PC = Cast<APlayerController>(Exiting);
if (!PC) return;
AMyPlayerState* PS = PC->GetPlayerState<AMyPlayerState>();
if (PS)
{
// 保存玩家数据
SavePlayerData(PS);
// 通知其他玩家
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
APlayerController* Other = It->Get();
if (Other && Other != PC)
{
Other->ClientNotifyPlayerLeft(PS->GetPlayerName());
}
}
}
// 销毁玩家的Pawn
if (PC->GetPawn())
{
PC->GetPawn()->Destroy();
}
Super::Logout(Exiting);
}
8.4 断线重连
8.4.1 服务端:保存玩家状态
cpp
UCLASS()
class AMyGameMode : public AGameModeBase
{
public:
virtual void Logout(AController* Exiting) override
{
APlayerController* PC = Cast<APlayerController>(Exiting);
// 修复:必须检查 PlayerState 是否有效,未完成连接的玩家可能没有 PlayerState
if (!PC || !PC->GetPlayerState<APlayerState>()) return;
FUniqueNetIdRepl UniqueId = PC->GetPlayerState<APlayerState>()->GetUniqueId();
// 保存断开连接的玩家
FPlayerSaveData SaveData;
SaveData.UniqueId = UniqueId;
SaveData.PlayerName = PC->GetPlayerState<APlayerState>()->GetPlayerName();
SaveData.Location = PC->GetPawn()->GetActorLocation();
SaveData.Health = GetHealthFromPawn(PC->GetPawn());
SaveData.DisconnectTime = GetWorld()->GetTimeSeconds();
DisconnectedPlayers.Add(UniqueId, SaveData);
// 10秒后清理
FTimerHandle Timer;
GetWorldTimerManager().SetTimer(Timer, [this, UniqueId]()
{
// 如果玩家没有重连,移除数据
if (DisconnectedPlayers.Contains(UniqueId))
{
DisconnectedPlayers.Remove(UniqueId);
}
}, 10.0f, false);
Super::Logout(Exiting);
}
virtual void PostLogin(APlayerController* NewPlayer) override
{
// 修复:确保 PlayerState 存在,避免直接访问空指针崩溃
if (!NewPlayer || !NewPlayer->GetPlayerState<APlayerState>())
{
Super::PostLogin(NewPlayer);
return;
}
FUniqueNetIdRepl UniqueId = NewPlayer->GetPlayerState<APlayerState>()->GetUniqueId();
// 检查是否是断线重连的玩家
if (DisconnectedPlayers.Contains(UniqueId))
{
FPlayerSaveData& SaveData = DisconnectedPlayers[UniqueId];
// 恢复位置和血量
if (APawn* OldPawn = FindSavedPawn(SaveData))
{
NewPlayer->Possess(OldPawn);
SetPawnHealth(OldPawn, SaveData.Health);
}
else
{
RestartPlayer(NewPlayer);
}
DisconnectedPlayers.Remove(UniqueId);
// 通知客户端恢复状态
NewPlayer->ClientRestoreState(SaveData);
}
else
{
// 新玩家
Super::PostLogin(NewPlayer);
}
}
private:
TMap<FUniqueNetIdRepl, FPlayerSaveData> DisconnectedPlayers;
float ReconnectTimeout = 10.0f;
};
8.4.2 客户端:主动重连
cpp
UCLASS()
class AMyPlayerController : public APlayerController
{
public:
virtual void Tick(float DeltaTime) override
{
Super::Tick(DeltaTime);
// 检测断线
if (GetNetConnection() && GetNetConnection()->State == USOCK_Closed)
{
if (!bIsReconnecting)
{
StartReconnect();
}
}
}
void StartReconnect()
{
bIsReconnecting = true;
ReconnectAttempts = 0;
// 延迟1秒后开始重连
FTimerHandle Timer;
GetWorldTimerManager().SetTimer(Timer, this, &AMyPlayerController::AttemptReconnect, 1.0f, false);
}
void AttemptReconnect()
{
if (ReconnectAttempts >= 5)
{
// 重连失败,返回主菜单
ReturnToMainMenu();
return;
}
// 重新连接
FString LastURL = GetLastConnectionURL();//自己定义
ClientTravel(LastURL, TRAVEL_Absolute);
ReconnectAttempts++;
// 3秒后检查是否连接成功
FTimerHandle Timer;
GetWorldTimerManager().SetTimer(Timer, [this]()
{
if (GetNetConnection() && GetNetConnection()->State == USOCK_Open)
{
bIsReconnecting = false;
// 重连成功,通知服务器恢复状态
ServerRequestRestore();
}
}, 3.0f, false);
}
UFUNCTION(Server, Reliable)
void ServerRequestRestore()
{
// 服务器恢复玩家状态
}
private:
bool bIsReconnecting = false;
int32 ReconnectAttempts = 0;
};
九、会话管理:Session 与 OnlineSubsystem
9.1 什么是 Session?
Session 就是"游戏房间"或"游戏会话"。
生活例子:
- 你想和朋友联机打游戏
- 你先创建一个房间(Create Session)
- 朋友搜索房间(Find Sessions)
- 朋友加入你的房间(Join Session)
Session 包含的信息:
- 房间名称
- 当前玩家数量 / 最大玩家数量
- 是否是局域网
- 自定义数据(地图名称、游戏模式、密码等)
- 房间的IP地址和端口
9.2 OnlineSubsystem 是什么?
OnlineSubsystem 是 UE5 的"平台抽象层"。
问题:Steam、Epic、PlayStation、Xbox 的联机API都不一样。如果每个平台都写一遍,代码会非常乱。
解决 :UE5 提供了统一的 IOnlineSubsystem 接口。你写的代码在Steam上运行,会自动调用Steam的SDK;在Epic上运行,会自动调用EOS的API。
🎮 你的游戏代码
🔌 IOnlineSession
(统一接口)
Steam
Epic (EOS)
PS5
Xbox
Null
(测试用)
9.3 获取 OnlineSubsystem
cpp
// 获取当前的OnlineSubsystem
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
if (OSS)
{
// 获取Session接口
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
// 获取朋友接口
IOnlineFriendsPtr Friends = OSS->GetFriendsInterface();
// 获取成就接口
IOnlineAchievementsPtr Achievements = OSS->GetAchievementsInterface();
}
else
{
// 没有OnlineSubsystem(比如编辑器里运行,没有Steam)
// 可以用 NullSubsystem 做测试
OSS = Online::GetSubsystem(GetWorld(), TEXT("NULL"));
}
9.4 创建 Session(开房间)
cpp
UCLASS()
class USessionManager : public UObject
{
GENERATED_BODY()
public:
void CreateGameSession(int32 MaxPlayers, bool bIsLAN, const FString& ServerName)
{
// 1. 获取 OnlineSubsystem
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
if (!OSS) return;
// 2. 获取 Session 接口
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
// 3. 配置 Session 设置
FOnlineSessionSettings SessionSettings;
SessionSettings.NumPublicConnections = MaxPlayers; // 最大玩家数
SessionSettings.bIsLANMatch = bIsLAN; // 是否局域网
SessionSettings.bShouldAdvertise = true; // 是否允许被搜索到
SessionSettings.bAllowJoinInProgress = true; // 游戏中能否加入
SessionSettings.bAllowInvites = true; // 是否允许邀请
SessionSettings.bUsesPresence = true; // 使用在线状态
// 自定义数据
SessionSettings.Set(TEXT("SERVER_NAME"), ServerName, EOnlineDataAdvertisementType::ViaOnlineService);
SessionSettings.Set(TEXT("GAME_MODE"), TEXT("DeathMatch"), EOnlineDataAdvertisementType::ViaOnlineService);
// 4. 绑定回调
OnCreateSessionCompleteDelegate = FOnCreateSessionCompleteDelegate::CreateUObject(
this, &USessionManager::OnCreateSessionComplete);
Sessions->AddOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegate);
// 5. 创建 Session
Sessions->CreateSession(0, NAME_GameSession, SessionSettings);
}
private:
void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
if (bWasSuccessful)
{
// 创建成功!现在作为服务器启动游戏
// 监听服务器的地图
GetWorld()->ServerTravel("/Game/Maps/GameMap?listen");
}
else
{
UE_LOG(LogTemp, Error, TEXT("创建Session失败!"));
}
}
FDelegateHandle OnCreateSessionCompleteDelegate;
};
9.5 查找 Session(搜索房间)
cpp
void FindGameSessions(bool bIsLAN)
{
// 1. 获取系统
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
// 2. 创建搜索设置
SessionSearch = MakeShareable(new FOnlineSessionSearch());
SessionSearch->MaxSearchResults = 100; // 最多找100个
SessionSearch->bIsLanQuery = bIsLAN; // 搜索局域网还是互联网
// 搜索条件(可选)
SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
// 3. 绑定回调
OnFindSessionsCompleteDelegate = FOnFindSessionsCompleteDelegate::CreateUObject(
this, &USessionManager::OnFindSessionsComplete);
Sessions->AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);
// 4. 开始搜索
Sessions->FindSessions(0, SessionSearch.ToSharedRef());
}
void OnFindSessionsComplete(bool bWasSuccessful)
{
if (!bWasSuccessful || !SessionSearch.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("搜索Session失败"));
return;
}
// 遍历搜索结果
for (const FOnlineSessionSearchResult& Result : SessionSearch->SearchResults)
{
// 获取房间名称
FString ServerName;
Result.Session.SessionSettings.Get(TEXT("SERVER_NAME"), ServerName);
// 获取玩家数量
int32 CurrentPlayers = Result.Session.SessionSettings.NumPublicConnections - Result.Session.NumOpenPublicConnections;
int32 MaxPlayers = Result.Session.SessionSettings.NumPublicConnections;
// 获取延迟(ping)
int32 Ping = Result.PingInMs;
UE_LOG(LogTemp, Log, TEXT("找到房间: %s (%d/%d) Ping: %dms"),
*ServerName, CurrentPlayers, MaxPlayers, Ping);
// 显示在UI上...
}
}
TSharedPtr<FOnlineSessionSearch> SessionSearch;
FDelegateHandle OnFindSessionsCompleteDelegate;
9.6 加入 Session(进房间)
cpp
void JoinGameSession(int32 SessionIndex)
{
// 1. 检查索引是否有效
if (!SessionSearch.IsValid() || !SessionSearch->SearchResults.IsValidIndex(SessionIndex))
return;
// 2. 获取系统
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
// 3. 获取选中的房间
const FOnlineSessionSearchResult& Result = SessionSearch->SearchResults[SessionIndex];
// 4. 绑定回调
OnJoinSessionCompleteDelegate = FOnJoinSessionCompleteDelegate::CreateUObject(
this, &USessionManager::OnJoinSessionComplete);
Sessions->AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);
// 5. 加入 Session
Sessions->JoinSession(0, NAME_GameSession, Result);
}
void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
if (Result == EOnJoinSessionCompleteResult::Success)
{
// 获取连接地址
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
FString ConnectString;
if (Sessions->GetResolvedConnectString(NAME_GameSession, ConnectString))
{
// 连接服务器
APlayerController* PC = GetWorld()->GetFirstPlayerController();
PC->ClientTravel(ConnectString, TRAVEL_Absolute);
}
}
else
{
UE_LOG(LogTemp, Error, TEXT("加入Session失败!"));
}
}
FDelegateHandle OnJoinSessionCompleteDelegate;
9.7 销毁 Session(关闭房间)
cpp
void DestroyGameSession()
{
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld());
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
// 销毁 Session
Sessions->DestroySession(NAME_GameSession);
// 可选:返回主菜单
GetWorld()->GetFirstPlayerController()->ClientTravel("/Game/Maps/MainMenu", TRAVEL_Absolute);
}
9.8 测试用的 NullSubsystem
开发时没有Steam怎么办?UE5提供了 NullSubsystem:
cpp
// 强制使用 NullSubsystem(在 DefaultEngine.ini 中配置)
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/OnlineSubsystemNull.NullNetDriver",DriverClassNameFallback="/Script/Engine.IpNetDriver")
[/Script/OnlineSubsystemNull.OnlineSubsystemNull]
bShouldLoadDefaultConfig=true
或者代码中临时指定:
cpp
// 使用 NullSubsystem(不依赖任何平台)
IOnlineSubsystem* OSS = Online::GetSubsystem(GetWorld(), TEXT("NULL"));
十、关卡跳转:Server Travel 与 Client Travel
10.1 Travel 类型
| 类型 | 说明 | 使用场景 |
|---|---|---|
| TRAVEL_Absolute | 完全卸载当前关卡,加载新关卡 | 切换地图、返回主菜单 |
| TRAVEL_Relative | 基于当前URL追加路径 | 较少使用 |
| TRAVEL_Partial | 部分跳转(已弃用) | 不要用 |
10.2 Server Travel(服务器跳转)
作用:服务器切换到新关卡,所有客户端自动跟随。
使用场景:
- 游戏开局,从大厅进入游戏地图
- 通关后切换到下一关
- 比赛结束,切换到结算界面
cpp
// 在 GameMode 中调用
void AMyGameMode::SwitchToNextLevel()
{
// 所有客户端都会跳转到这个地图
GetWorld()->ServerTravel("/Game/Maps/Level2?listen");
}
// 带参数跳转
void AMyGameMode::SwitchWithDifficulty(int32 Difficulty)
{
FString URL = FString::Printf(TEXT("/Game/Maps/GameMap?listen?Difficulty=%d"), Difficulty);
GetWorld()->ServerTravel(URL);
}
// 通知客户端准备跳转
void AMyGameMode::TransitionToNewLevel(const FString& LevelName)
{
// 先让所有客户端显示加载界面
for (APlayerController* PC : GetWorld()->GetPlayerControllerIterator())
{
PC->ClientPrepareForTravel(LevelName);
}
// 保存游戏状态
SaveGameState();
// 跳转
GetWorld()->ServerTravel(FString::Printf(TEXT("/Game/Maps/%s?listen"), *LevelName));
}
// 客户端:显示加载界面
void AMyPlayerController::ClientPrepareForTravel_Implementation(const FString& LevelName)
{
ShowLoadingScreen(LevelName);
}
10.3 Client Travel(客户端跳转)
作用:只有当前客户端自己跳转。
使用场景:
- 返回主菜单
- 连接到其他服务器
- 断线重连
cpp
// 返回主菜单
void AMyPlayerController::ReturnToMainMenu()
{
// 通知服务器我要离开
ServerNotifyLeave();
// 自己跳转回主菜单
ClientTravel("/Game/Maps/MainMenu", TRAVEL_Absolute);
}
// 连接到指定服务器
void AMyPlayerController::JoinServer(const FString& IP, int32 Port)
{
FString URL = FString::Printf(TEXT("%s:%d"), *IP, Port);
ClientTravel(URL, TRAVEL_Absolute);
}
// 断线重连
void AMyPlayerController::Reconnect()
{
FString LastURL = GetLastConnectionURL();
ClientTravel(LastURL, TRAVEL_Absolute);
}
10.4 跳转时的参数传递
服务器跳转时带上参数:
cpp
// 服务器跳转时带参数
GetWorld()->ServerTravel("/Game/Maps/GameMap?listen?GameMode=DeathMatch?MaxPlayers=10");
新地图中读取参数:
cpp
void AMyGameMode::BeginPlay()
{
Super::BeginPlay();
// 获取URL选项
FString Options = GetWorld()->URL.Options;
// 读取参数
FString GameModeType = UGameplayStatics::ParseOption(Options, "GameMode");
int32 MaxPlayers = FCString::Atoi(*UGameplayStatics::ParseOption(Options, "MaxPlayers"));
if (GameModeType == "DeathMatch")
{
// 设置为死斗模式
}
}
10.5 跨关卡状态保持
跳转后,默认所有Actor都会被销毁。如果有些数据要保留,用以下几种方法:
方法1:使用 GameInstance(最常用)
cpp
UCLASS()
class UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
// GameInstance 在关卡跳转时不会被销毁
UPROPERTY()
int32 GlobalScore = 0;
UPROPERTY()
FString PlayerName;
UPROPERTY()
TArray<FItemData> Inventory;
};
方法2:设置 Actor 为 Persistent
cpp
UCLASS()
class APersistentActor : public AActor
{
GENERATED_BODY()
public:
APersistentActor()
{
// 这个Actor在关卡跳转时不会被销毁
bReplicates = true;
bAlwaysRelevant = true;
}
virtual bool IsLevelBoundsRelevant() const override
{
return false; // 无视关卡边界
}
};
方法3:保存到 PlayerState
cpp
// PlayerState 默认会在跳转时保留(如果没被重置)
UCLASS()
class AMyPlayerState : public APlayerState
{
GENERATED_BODY()
public:
UPROPERTY(Replicated)
int32 TotalKills = 0;
UPROPERTY(Replicated)
int32 TotalDeaths = 0;
};
方法4:手动保存和加载
cpp
void AMyGameMode::SaveGameState()
{
// 保存到存档文件或 GameInstance
UMyGameInstance* GI = GetGameInstance<UMyGameInstance>();
GI->PlayerHealth = CurrentPlayerHealth;
GI->PlayerScore = CurrentPlayerScore;
}
void AMyGameMode::LoadGameState()
{
UMyGameInstance* GI = GetGameInstance<UMyGameInstance>();
CurrentPlayerHealth = GI->PlayerHealth;
CurrentPlayerScore = GI->PlayerScore;
}