多人项目里最常见的架构争论:"玩家分数/比赛进度/房间配置到底存哪?"四个候选(GameMode、GameState、PlayerController、PlayerState)各有官方说法,但官方说法记不住,判断框架才记得住:按"谁需要看到这段数据"划线。源码依据:Engine/Source/Runtime/Engine/Classes/GameFramework/ 下的 GameModeBase.h、GameStateBase.h、PlayerState.h(UE5.3)。
四层的可见性矩阵
先把四个类的复制范围钉死,存放问题就变成查表:
| 类 | 只在服务端 | 复制到所有客户端 | 跨地图存活 |
|---|---|---|---|
| GameMode | ✓ | ✗(设计为不复制) | ✗(seamless travel 也重建) |
| GameState | ✓ | ✓ | ✗(重建,但会转移) |
| PlayerState | ✓ | ✓(PlayerArray 统一复制) | ✓(seamless travel 默认转移) |
| PlayerController | ✓ | 只复制给所属客户端 | ✓(seamless travel 默认转移) |
GameMode 不复制的原因上一篇初始化链路里讲过:它是权威裁决者,客户端持有裁决者等于把规则交给不可信端。剩下三层的差异才是日常设计的战场。
判断流程:数据属于谁
拿不准时按这个顺序问:
1. 服务器私有还是客户端可见? 私有(反作弊判定中间量、刷怪权重)→ 留在 GameMode 或自定义服务端对象,不往下走。客户端可见继续。
2. 属于"这场对局"还是属于"某个玩家"? 比分板、回合数、全局 buff —— 属于对局 → GameState。击杀数、段位、皮肤选择 —— 属于玩家 → PlayerState。这条线画错的代价不对称:把玩家数据塞进 GameState,你就得手动维护"哪个槽位是谁"的映射(其实 GameState 的 PlayerArray 就是干这个的,绕一圈等于重造 PlayerState);把对局数据塞进 PlayerState,客户端要拼"当前回合数"就得遍历所有玩家取最大值,逻辑又丑又脆。
3. 敏感吗? 这是 PC 和 PlayerState 的分界线:PlayerController 只复制给自己的客户端,其他客户端根本收不到它的属性。所以"只该本人知道"的数据——未公开的手牌、本机输入配置、观测者名单——放 PC 上的自定义组件/属性。反过来,任何要给别人看的数据(头顶血条、击杀播报)都不能放 PC,因为其他客户端拿不到。PlayerState 是全可见的,放它就是公开广播。
4. 跨地图要带吗? seamless travel 下 PC 和 PlayerState 默认进转移名单(GetSeamlessTravelActorList),GameState 重建。所以玩家战绩天然该在 PlayerState 上——不是风格偏好,是引擎行为:放 GameState 的数据换图即失,你得自己写转移逻辑兜底。
PlayerArray:为什么查玩家走 GameState
GameState 里有个容易误解的成员 PlayerArray(TArray<APlayerState*>)。它不是"备选存储",而是客户端枚举玩家实体唯一可靠入口:PlayerState 虽然复制,但客户端要知道"都有哪些 PlayerState 活着",靠的就是 GameState 复制下来的这个数组。写"遍历所有玩家发公告"的逻辑时从 GetGameState()->PlayerArray 出发,不要自己维护列表——玩家进出、断线重连时数组由引擎增删,自建列表永远慢半拍,漏掉的那个 null 槽位就是崩溃来源。
对应的,PC 不从数组里找。"我的 PC"是 GetWorld()->GetFirstPlayerController() 或从本地玩家上下文拿;别人的 PC 客户端压根不存在这个对象(其他客户端只有"别人的 PlayerState"),这是常犯的错误——在客户端写 遍历 PlayerArray 拿 GetPlayerController() 拿回来全是 null。
一个完整的实例:FPS 计分板
把上面的流程跑一遍。需求:记分板显示全员击杀/死亡/ ping,玩家自己额外看到命中反馈统计,服务端有反作弊的射击判定记录。
- 击杀/死亡:全员可见 + 属于玩家 →
APlayerState子类加KillCount/DeathCount(UPROPERTY(Replicated)); - ping:
APlayerState内建(GetPing()),从GetPlayerController()->PlayerState来,不用自己测; - 命中反馈统计(本人可见)→ 自定义 PC 上的属性,或干脆放本地 UI 层不复制;
- 射击判定原始数据(服务端私有)→ GameMode 侧的判定组件,不进复制体系。
计分板 UI 订阅 GameState 的 PlayerArray 变化,每个条目绑一个 PlayerState 的 OnRep——属性到界面的链路没有一处需要自定义复制代码。
常见踩坑
- 在客户端改 PlayerState 的属性:PlayerState 的权威在服务端,客户端改了会被下次复制覆盖,看起来"灵异复位"。表现类字段(本地 UI 高亮)放 PC 或本地对象。
- 把 GameMode 的指针缓存到蓝图变量:客户端上它是 null,
IsValid判空之外要意识到"这段逻辑本来就不该在客户端跑"。 - 用 PlayerState 存对局临时状态("这回合谁先到 10 分"),换图或重进时丢失还要手动同步——对局状态就该 GameState 管。
- 在 PlayerState 上放大数据(完整背包物品列表):PlayerState 每个客户端全量复制一份,背包这种结构该走专门的库存组件 + 条件复制(COND_OwnerOnly),或者独立 Actor + 相关性。