Unreal Engine 5 联机网络架构技术手册


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:**只在服务器上修改这个变量的值**
      • [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)字段有效

核心规则(重要)

  1. SYN和FIN消耗一个序列号
    即使SYN报文不携带应用数据,它也会占用一个序号。这就是为什么第①步的seq=x,到第③步变成seq=x+1(因为SYN被消耗掉了)。
  2. ACK标志与ack字段的关系
    只有ACK标志位=1时,报文中的ack字段才有意义。在三次握手中,除了第①步的纯SYN报文,第②步和第③步的ACK标志位都是1。
  3. 初始序列号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环境)

  1. 创建一个新的C++控制台项目
  2. 项目 → 属性 → C++ → 预处理器 → 预处理器定义,添加 _WINSOCK_DEPRECATED_NO_WARNINGS
  3. 确保链接了 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;
}

如何运行测试

  1. 先运行 Server项目,你会看到"等待客户端连接..."
  2. 再运行 Client项目,客户端会连上服务器
  3. 服务器端显示"客户端已连接",并发送欢迎消息
  4. 客户端收到消息,然后发消息给服务器
  5. 服务器收到客户端的消息

运行效果

1.3 UDP

UDP的全称是 User Datagram Protocol(用户数据报协议)。

1.3.1 UDP的核心特点
特点 解释 比喻
无连接 发数据前不需要建立连接,直接发 寄平信:填好地址扔邮筒,不需要提前打招呼
不可靠 数据可能丢,可能乱序,可能重复 平信可能寄丢,也可能前后顺序错乱
无流量/拥塞控制 不管对方能不能处理,发了再说 不管对方听不听得清,你只管说
开销小 只有8字节的头部 明信片很薄,邮费便宜
1.3.2 UDP的包结构(为什么它快)
text 复制代码
TCP包(复杂,开销大):
[目标端口][源端口][序号][确认号][数据偏移][保留][标志位][窗口][校验和][紧急指针][选项...][数据...]
  ↑ 光是头部就有20-60字节


UDP包(简单,开销小):
[源端口][目标端口][长度][校验和][数据...]
  ↑ 只有8字节的头部

UDP快的秘密

  1. 没有建立连接的开销(不用三次握手)
  2. 没有确认机制(发完就忘)
  3. 没有重传机制(丢了也不补)
  4. 头部很短(只占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 技术原因
  1. UE5的底层不是确定性的
  • 物理引擎(PhysX)在不同帧率下结果不同
  • 浮点数计算在不同CPU上有误差
  • 动画系统的混合计算依赖时间
  1. UE5的设计目标是大世界、复杂场景
  • 帧同步适合小规模、确定性强的游戏
  • 状态同步适合开放世界、大量可交互物体
  1. 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自动:

  1. 检测到值变化了
  2. 打包变化的数据
  3. 发送给所有相关的客户端
  4. 客户端收到后自动更新本地 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;
}
相关推荐
pengyi8710151 小时前
HTTP与HTTPS代理基础区别,协议原理通俗解析
网络·爬虫·网络协议·tcp/ip·智能路由器
Giggle12181 小时前
上门家政服务平台 | 多端协同,源码交付,用户端小程序+H5、服务端APP、管理后台
java·小程序·架构·产品运营·个人开发
call me by ur name1 小时前
多模态大模型轻量化
前端·网络·人工智能
专注VB编程开发20年1 小时前
轻量级多进程消息收发模型WEBSOCKET,MQTT
网络·websocket·网络协议
Tim风声(网络工程师)9 小时前
排查内网互联网访问流程
运维·服务器·网络
一袋米扛几楼9812 小时前
【网络】网络规划与底层通信:自顶向下方法论 (Top-Down Methodology) 全解析
网络·工程
不懂的浪漫12 小时前
Netty 系列文章总览:从源码主线到业务架构判断
架构·netty
liulilittle12 小时前
TCP BBR 拥塞控制模块编译
网络·网络协议·tcp/ip
wangl_9213 小时前
Modbus RTU 与 Modbus TCP 深入指南-功能码与数据模型
网络·网络协议·tcp/ip·tcp·modbus·rtu