刚接触 UE4 网络同步那会儿,我在一个 PlayerController 里写了GetWorld()->GetAuthGameMode(),单机 PIE 里跑得好好的,打包成专用服务器、连上两个客户端之后,其中一个客户端的日志里直接蹦出空指针警告,紧接着就是一连串"读访问冲突"。排查了一下午才想明白:GameInstance、GameMode、GameState、PlayerState、PlayerController 这五个东西,不是五个可以随便互相 Get 的全局容器,它们的可见性被引擎的复制规则、生命周期和 Owner 链条严格划定了边界。谁在哪一端存在、谁在什么时刻出生、谁的数据会同步给别人,这些问题的答案都是硬性的,不因为你的代码写得漂亮就网开一面。
这篇内容我打算把这五者的关系从三个维度拆开讲:一是职权边界,也就是每个类到底该放什么数据;二是生命周期,从引擎启动到关卡切换,它们的出生与死亡顺序;三是复制视角,也就是服务器和客户端看到的"世界"有多不一样。中间会穿插我自己踩过的坑、排错的完整链路,以及数据归属的判断方法。适合已经能写基本 Actor 逻辑、但一碰网络同步就犯迷糊的开发者,也适合想重新梳理一遍架构的老手——很多"玄学 Bug"其实都是这层关系没理清。
1. 五个类的职权边界:先搞清谁管规则、谁管状态、谁管个人
在 UE4 里这五个类经常被新手当成"五个都能存数据的地方",然后随便挑一个塞进去,结果单机没问题、联机全崩。核心原因是它们的设计出发点完全不同:有的是引擎层的单例,有的是规则裁判,有的是状态公告板,有的是玩家档案,有的是输入与网络通道。先把定位摆正,后面所有问题都会顺很多。
1.1 GameInstance:唯一活得过关卡切换的引擎级单例
UGameInstance是这五个类里唯一一个不是 Actor 的,它是 UObject,由引擎在启动时创建,在引擎退出时才销毁。它最大的特点就是跨关卡存活:无论你OpenLevel切多少次图、无论中间经历了多少次玩家加入和离开,GameInstance 始终是同一个实例。引擎内部还挂在它下面维护着ULocalPlayer列表,这也是它和本地玩家系统绑定的证明。
正因为这个特性,GameInstance 天然适合放与单局比赛无关、但需要贯穿整个程序生命周期的数据:玩家的存档句柄、账号登录态、音效与画质的用户设置、跨关卡要传递的选人结果、网络会话的引用等等。我自己项目里就有一个UMyGameInstance,里面挂了一个USaveGame指针和一个FPlayerProfile结构体,无论玩家从主菜单进大厅、再进战斗地图、再回来,这些数据都不会丢。
但它的限制同样明显:第一,它不是 Actor,不能复制、不能用 RPC、不会自动同步到其他端。你在监听服务器上往 GameInstance 里写一个值,客户端那边的 GameInstance 完全不知道。第二,它没有 Tick,想做定时逻辑得自己用 TimerManager 或者放到别的地方。第三,它的生命周期太长,如果你把一局比赛的临时状态(比如当前回合数)放进去,下一局开始时你很容易忘记清理,造成"上一局的数据串到这一局"这种非常隐蔽的 Bug。
1.2 GameMode:只在服务器存在的规则裁判
AGameModeBase以及它的子类AGameMode,是这个体系的规则中枢。它决定:玩家进来时用哪个 PlayerController 类、默认 Pawn 用什么、GameState 和 PlayerState 用哪个子类、HUD 是什么、玩家死亡后怎么重生、什么时候算一局结束。注意这些全是"规则"和"决策",而不是"状态"。
它最关键的一个特性是:GameMode 只存在于服务器上(单机模式下本地就是权威端,所以 PIE 里你能拿到它)。它默认不复制,客户端上根本没有 GameMode 实例,你写GetWorld()->GetAuthGameMode()在客户端返回的一定是 nullptr。这个设计是合理的——规则只需要一个人说了算,没必要让每个客户端都知道"裁判的笔记本上写了什么"。
所以 GameMode 里适合放的是:不需要客户端知道的判定逻辑、服务器独占的作弊校验、刷怪与关卡流程控制、伤害计算的最终裁决。反过来,任何"客户端 UI 需要显示"的数据都不应该只放在 GameMode 里,否则客户端要么读不到,要么得靠 RPC 一个个推过去,费力不讨好。
1.3 GameState:全场共享的公告板
AGameStateBase是 GameMode 的"对外发言人"。它在服务器上被 GameMode 创建(大体是在InitGame阶段,具体时机随引擎版本略有差异),并且会复制到所有客户端。它的职责是承载"所有人都应该知道的全局状态":比赛阶段(MatchState,比如 WaitingToStart、InProgress、GameOver)、已经过去的时间ElapsedTime、双方队伍比分、以及最常用的PlayerArray——一个包含场上所有 PlayerState 的数组。
我一般把 GameState 理解成一块挂在广场中央的公告板:谁都能看,内容由服务器统一更新,客户端只能读不能改。想改数据,得走服务器侧的 GameMode 或者对 GameState 发起 Server RPC(前提是 Owner 链条允许,这个后面细说)。
AGameMode相比AGameModeBase多出来的那套 MatchState 流程,就是 Matinee/回合制玩法最常用的骨架。你要是做的是一个有明确"开始—进行—结算"三段式的玩法,直接用AGameMode会省不少事。
1.4 PlayerState:每个人的档案卡
APlayerState是"一个玩家的公开档案",服务器上每个玩家一份,并且会复制给所有客户端。注意这里是"所有客户端"——A 玩家能通过 GameState 的 PlayerArray 拿到 B 玩家的 PlayerState,看到他的名字、分数、队伍 ID。这正是它存在的意义:让每个人都能知道别人是谁、状态如何。
典型放进去的字段有:PlayerName(玩家昵称)、Score(个人得分)、TeamID、UniqueId(唯一标识,做断线重连认人很有用),以及你自己扩展的 K/D、击杀数、职业选择、准备状态等等。Ping这类只跟本人相关的数据通常用COND_OwnerOnly条件复制,省带宽也避免误导。
一个容易忽略的点:PlayerState 的 Owner 是 PlayerController。这个 Owner 关系决定了它上面的 Server RPC 能否被正确路由(后面第 3 节会展开)。另外,PlayerState 是会随关卡切换而重建的,除非你走无缝切换(SeamlessTravel)——很多人把"跨局保留的等级/经验"放这里,然后奇怪为什么切图之后全没了,就是踩了这个坑。
1.5 PlayerController:玩家的意志与外网通道
APlayerController是这五个类里唯一直接对应"一个真实玩家的一条连接"的对象。它负责把输入变成动作(InputComponent、按键绑定、外接设备映射的落点),管理 Camera 与视角,并且承担唯一可靠的客户端到服务器的 RPC 通道。玩家在客户端上按下一个键、点一下 UI 按钮,最终要影响服务器,几乎都要经过 PlayerController 发起 Server RPC。
它还有一个非常硬性的特性:PlayerController 默认是bOnlyRelevantToOwner,只复制给拥有它的那个客户端。也就是说,A 客户端的机器上根本不存在 B 玩家的 PlayerController 对象。你想在 A 那边获取 B 玩家按了什么键,是拿不到的——那种信息必须经过 PlayerState 这类公共载体,或者由服务器转发 NetMulticast RPC。
所以我的经验是:任何"只有本人需要知道"的东西放 PlayerController,任何"别人也需要知道"的东西放 PlayerState,任何"全场需要知道"的东西放 GameState。这条线划清楚了,后面复制和 RPC 的问题能少掉一大半。
2. 生命周期时间轴:从引擎启动到关卡切换,谁先出生谁先死
理解了职权边界,还得知道时间顺序。很多 Bug 不是"数据放错了类",而是"你在它还没出生的时候就去拿它"——比如在 PlayerController 的构造函数里访问 GameState,或者在 GameInstance 的Init里访问 PlayerController,这些时序上根本不可能成立。
2.1 引擎启动到地图加载完成:GameInstance 先落地
整个链条的起点是引擎启动。GameInstance 在这一步被创建(Init()被调用),此时它手里还没有 World,更没有玩家。接着引擎加载默认地图,UWorld建立,然后根据 World Settings 里指定的 GameMode 类去 Spawn GameMode 实例——这里要注意,GameMode 是跟着地图走的,换一张地图就会换一个 GameMode 实例(除非是无缝切换且 GameMode 类相同,引擎倾向复用)。
GameMode 出生后依次经过InitGame、PreInitializeComponents、StartPlay等阶段,其中在InitGame附近会创建 GameState。所以正常流程下,GameState 一定比任何玩家都早存在。GameMode 的StartPlay之后,比赛进入正式运行状态。
这里有个实操习惯我强烈建议:在 GameInstance 的Init里别碰任何 World 相关的东西。我见过有人在Init里写GetWorld()->SpawnActor(...),直接崩在启动阶段,因为那一刻 GameInstance 还没被赋予 Outer World。要初始化跨关卡的数据,用Init里只做纯数据准备,需要 World 的逻辑推迟到第一次进入关卡时做。
2.2 一个玩家进服的四步:Login、InitPlayerState、PostLogin、RestartPlayer
玩家连接进来之后,服务器侧的流程大致是这样四步,每一步该做什么事是有讲究的:
- Login:GameMode 被调用
Login,在这里决定是否允许加入,并 Spawn 出一个 PlayerController。这一步是"分配座位"。 - PlayerState 初始化:PlayerController 会创建自己的 PlayerState 并建立 Owner 关系。这一步是"发档案卡"。
- PostLogin:PlayerState 就绪之后,GameMode 的
PostLogin被调用。这是做初始化最常用、最安全的钩子——比如设置初始分数、把玩家加入队伍、广播"有新玩家加入"。因为此时 PlayerController 和 PlayerState 都已经有效,访问它们不会拿到空指针。 - RestartPlayer:最后 GameMode 通过
RestartPlayer给玩家生成 Pawn 并 Possess,玩家这才真正"出现在场上"。
顺序上的关键结论是:在 PostLogin 里,PC 和 PS 都有了,但 Pawn 可能还没有。很多新手在PostLogin里写GetPawn()然后直接用,结果拿到 nullptr。需要操作 Pawn 的逻辑,要么放在RestartPlayer之后,要么监听APlayerController::OnPossess或者 Pawn 的BeginPlay。
2.3 关卡切换的生死名单:谁被销毁、谁被带走
关卡切换是这五个类命运分歧最大的时刻,用一张表最直观:
| 对象 | 普通切换(OpenLevel) | 无缝切换(SeamlessTravel) |
|---|---|---|
| GameInstance | 保留 | 保留 |
| GameMode | 销毁重建 | 通常复用(同类时) |
| GameState | 销毁重建 | 重建并转移部分数据 |
| PlayerState | 销毁重建 | 默认保留 |
| PlayerController | 销毁重建 | 默认保留 |
| Pawn | 销毁重建 | 销毁重建 |
普通切换时,除了 GameInstance,其他全部重来一遍。这就是为什么"跨关卡的等级、背包、进度"一定要放 GameInstance,不能放 PlayerState。而如果你做的是房间制玩法,一局一局之间不想让玩家重新加入(重新握手、重新加载资源),那就该走无缝切换,让 PlayerController 和 PlayerState 被带到下一张图里。
无缝切换有两个前置条件:服务器用ServerTravel加?game=之类的选项触发,并且你要在GetSeamlessTravelActorList里明确指出需要额外保留的 Actor。踩坑点在于:Pawn 一定不会保留,所以别把重要数据挂在 Pawn 上;以及无缝切换时PostLogin不会被再次调用,玩家重新回到场景是靠HandleSeamlessTravelPlayer,如果你把初始化逻辑全写在PostLogin里,切图之后玩家就会处于"半初始化"状态——这是我见过最多的一类无缝切换 Bug。
3. 服务器与客户端视角差异:为什么 GameMode 在客户端永远是空
如果让我只挑一个知识点推荐给所有刚做联机的人,那就是这一节。理解"不同端看到的对象集合不一样",能省掉你至少一半的排错时间。
3.1 一张复制规则表,胜过十次试错
| 类 | 服务器 | 拥有它的客户端 | 其他客户端 |
|---|---|---|---|
| GameInstance | 有 | 有(各自独立) | 有(各自独立) |
| GameMode | 有 | 无 | 无 |
| GameState | 有 | 有 | 有 |
| PlayerState | 有 | 有 | 有 |
| PlayerController | 有 | 有 | 无 |
这张表里最容易被忽视的是最后一行。A 客户端上只能拿到自己的 PlayerController,拿不到 B 的。做观战系统、做"显示队友正在看的方向"这类需求时,如果你第一反应是"遍历所有 PlayerController",在客户端一定跑不通——正确做法是把需要公开的信息放到 PlayerState 的自定义字段里复制出去,或者由服务器主动发 NetMulticast RPC。
GameInstance 那一行也值得说一句:三个端"都有",但它们是三个互不相干的对象。服务器 GameInstance 里的变量改了,客户端 GameInstance 里的同名变量不会被改。这一点和 GameState 形成鲜明对比,把 GameInstance 当同步容器用是最常见的误解之一。
3.2 Owner 链条决定了 RPC 能往哪儿发
RPC 不是随便哪个 Actor 都能发的。规则是:
- Server RPC:只有"由本地玩家拥有的、且本地有控制权的 Actor"才能调用成功。PlayerController 天然满足;它 Possess 的 Pawn、以及 Owner 链条最终指回该 PC 的 Actor(比如 PlayerState、PlayerController 创建的 HUD)也满足。
- Client RPC:只能发到该 Actor 的 Owner 客户端。你要让"所有客户端同时播一个特效",就用 NetMulticast,而不是循环发 Client RPC。
- NetMulticast RPC:只要这个 Actor 被复制到了某个客户端,那个客户端就能收到。所以 GameState 上挂 NetMulticast 广播是很常见的做法——比如"比赛开始"这种全场事件。
这里有个很多人不知道的细节:PlayerState 上也能写 Server RPC。因为 PlayerState 的 Owner 就是对应的 PlayerController,Owner 链条是通的。有些需求(比如玩家点"准备"按钮)写在 PlayerState 上反而更自然,因为它同时是复制给所有人的,服务端改个 bool 就能让所有人看到准备状态变化。
3.3 一次空指针排查的完整链路
回到开头那个空指针。当时的场景是一个 UI 面板,在NativeConstruct里去GetWorld()->GetAuthGameMode()取当前回合数。单机没问题,联机时客户端炸。
排查链路是这样的:第一步,看崩的是哪一端——日志里的NetMode显示是客户端;第二步,确认GetAuthGameMode()的实现,它在非服务器端直接返回 nullptr,这是设计如此,不是 Bug;第三步,看这个数据本来该从哪儿来——回合数属于"全场共享的全局状态",归 GameState 管;第四步,检查 GameState 里有没有这个字段,发现确实没有,只在 GameMode 里存了一份;第五步,把字段挪到 GameState,服务器端由 GameMode 更新它(因为 GameState 的成员变量在服务器上可以直接写),客户端用GetWorld()->GetGameState<T>()读取。
改完之后还要注意一件事:如果这个字段是运行时动态变化的,必须在GetLifetimeReplicatedProps里声明复制,并且最好用ReplicatedUsing绑一个 OnRep 回调去刷新 UI,否则客户端可能拿到旧值。这个坑我在第 6 节还会展开。
4. 数据归属判断:一个新需求来了,字段到底该挂在哪
这是日常开发里最高频的决策。我的做法是连续问自己三个问题,基本能在半分钟内定位。
4.1 三个自问:可见范围、存活周期、是否要权威
第一个问题:这个数据谁需要看到?只有本人 → PlayerController;所有人都要看到 → PlayerState(每人的)或 GameState(全局的);只有服务器需要 → GameMode。
第二个问题:这个数据要活多久?只活一局 → GameState / PlayerState;要跨关卡活 → GameInstance;只活一次交互(比如一次按键、一次点击)→ 直接 RPC 传参,别存。
第三个问题:这个数据需要服务器权威吗?涉及分数、伤害、胜负、道具数量这类会被玩家"想办法修改"的,必须放服务器权威的地方(GameMode 计算、GameState / PlayerState 复制出去)。纯表现的、不影响胜负的(比如本地摄像机抖动)可以放客户端本地,不必复制。
这三个问题串起来,你会发现绝大多数归类困难的需求都能被拆开:比如"击杀提示"其实拆成了"击杀计数(PlayerState,权威)"和"屏幕上的飘字(客户端本地表现)"两部分,硬塞进一个类里反而别扭。
4.2 常见字段的落位对照表
| 数据 | 推荐位置 | 理由 |
|---|---|---|
| 玩家昵称、头像 ID | PlayerState | 所有人都要看到 |
| 个人得分、K/D | PlayerState | 权威计算,全员可见 |
| 队伍总分 | GameState | 全局唯一,全员可见 |
| 比赛阶段、剩余时间 | GameState | 全员需要同步的时钟 |
| 当前回合数 | GameState | 全员可见的全局状态 |
| 玩家等级、背包、存档 | GameInstance | 跨关卡存活 |
| 用户音量、画质设置 | GameInstance | 程序级配置 |
| 按键绑定、输入映射 | PlayerController(+ GameInstance 存配置) | 输入落点,配置要持久化 |
| 本局是否按下冲刺(瞬时) | PlayerController | 只有本人和服务器关心 |
| 观战目标索引 | PlayerState | 其他人也可能需要渲染 |
表里最后一条我要多说一句:观战目标看起来是"本地选择",但如果服务器要把你观战的对象同步给导播系统或者其他观战者,那就变成公开信息了,放 PlayerState 更合适。判断依据永远是"可见范围",而不是"我第一反应觉得它属于谁"。
4.3 状态查询和物理模拟不是一回事
这一点在热词里也有人问:UE4 里"查询状态"和"物理模拟"的区别,在网络同步语境下非常关键。
GameState、PlayerState 里放的数据是逻辑状态,它们是布尔值、整数、枚举、结构体,由服务器权威修改后原样复制。客户端拿到的是"服务器告诉我现在是这样",而不是自己去推算。这类数据的特征是可枚举、可比较、可以在 UI 上直接显示。
而物理模拟(载具、布娃娃、可推动的箱子)的结果是连续浮点数,由各端各自的物理引擎独立演算。你不可能把每一帧的位置和速度全量复制过去——带宽受不了。所以 UE4 的做法是:服务器权威模拟、客户端预测、必要时做平滑校正。这意味着不要把物理模拟的中间结果塞进 PlayerState 或 GameState 当状态用,因为那些数值在客户端和服务器上本来就不完全一致,你拿来做判定逻辑会得到"两边算不一样的答案"这种诡异现象。
我实际项目里的做法是:物理相关的判定全部在服务器做(比如"这个箱子砸到玩家了没"),把结论性的逻辑状态(扣了多少血、是否被击倒)写进 PlayerState 复制出去;客户端只负责根据这个结论播表现。这条分界线划清楚,联机手感会稳定很多。
5. 获取与 Cast 的正确姿势:C++ 和蓝图里的常用写法
知道了谁在哪,还得知道怎么拿到它。这一段我按使用频率列一遍,包括那些容易"看起来能拿到其实拿不到"的写法。
5.1 C++ 侧的取用方式
在大多数AActor或UActorComponent里,标准写法是:
// GameInstance:任何有 World 的地方都能拿 UMyGameInstance* GI = GetWorld()->GetGameInstance<UMyGameInstance>(); // GameMode:只有服务器有效,客户端返回 nullptr AGameModeBase* GM = GetWorld()->GetAuthGameMode(); AMyGameMode* MyGM = GetWorld()->GetAuthGameMode<AMyGameMode>(); // GameState:所有端都能拿 AMyGameState* GS = GetWorld()->GetGameState<AMyGameState>(); // PlayerController:拿本地第一个玩家 APlayerController* PC = GetWorld()->GetFirstPlayerController(); // PlayerState:从 PC 或 Pawn 反查 APlayerState* PS = PC ? PC->PlayerState : nullptr;几个容易被忽略的细节。
第一,GetAuthGameMode()和GetGameMode()有区别。前者只在权威端返回实例,后者在某些版本/上下文中可能返回一个 CDO 或者空,不要混用。养成一律用GetAuthGameMode()的习惯,出问题的时候至少能立刻意识到"哦,我在客户端"。
第二,GetGameInstance<T>()里的模板参数必须和 Project Settings 里配置的 GameInstance 类一致,否则 Cast 失败返回空。这地方配错了不太会报错,只会静默返回 nullptr,很难查。
第三,在 PlayerController 内部可以直接用PlayerState成员,Pawn 内部可以用GetPlayerState()、GetController(),这些走的是引擎已经建立好的引用,比全局查找更快也更安全。
5.2 蓝图与 UMG 里最容易拿空的三个节点
蓝图里对应的节点是Get Game Instance、Get Game Mode、Get Game State、Get Player Controller、Get Player State。坑主要集中在三个地方。
第一个坑:Get Game Mode在客户端返回空。这几乎是每个新手都会踩一次的。解决办法有两个,要么改用Get Game State,要么用IsStandalone/HasAuthority判断后再走不同分支。
第二个坑:UMG 控件里没有直接的 World 上下文时拿不到东西。控件的Get World在Construct之前可能是空的,稳妥做法是在NativeConstruct/Event Construct之后再去获取,并且加空判断。
第三个坑:Get Player Controller的 Index 参数。默认 0 号在单机和对战里通常没问题,但如果你做的是本地分屏,0 号只代表第一个玩家,另一个玩家的 UI 必须取 1 号。另外在纯服务器上(Dedicated Server),这个节点返回也是不可靠的,因为服务器上没有本地玩家——服务器上的玩家对象是 PlayerController,但不是"本地玩家"意义上的 FirstPlayerController。涉及服务器逻辑时,遍历PlayerArray比拿 0 号更靠谱。
5.3 从一个深层子对象反查 PC 的完整链路
实战里经常遇到这种情形:某个组件、某个 Actor 被放在很深的层级里,它手里只有一个AActor*,需要向上反查属于哪个玩家。我的标准链路是这样的:
// 从任意 Actor 出发,先找 Controller AController* Controller = nullptr; if (APawn* AsPawn = Cast<APawn>(Actor)) { Controller = AsPawn->GetController(); } if (!Controller) { Controller = Actor->GetInstigatorController(); } // 拿到 PlayerController,再拿 PlayerState APlayerController* PC = Cast<APlayerController>(Controller); APlayerState* PS = PC ? PC->PlayerState : nullptr;这个链路里GetInstigatorController()是个宝藏函数,尤其在投射物、特效、伤害事件里非常有用——它能帮你一路追回到"谁干的"。我在做伤害统计系统的时候就是靠它把伤害归属到具体玩家的 PlayerState 上的,比在每个地方手动传 PC 引用省事得多。
如果连 Instigator 都没有(比如环境伤害),那就该落到 GameState 的玩家数组里去做额外判定,或者干脆记到一个"世界规则"的数据结构里。这时候不要硬找一个 PC 塞进去,会让代码变得很难维护。
6. 踩坑实录:复制、重生、无缝切换里的典型故障
理论讲完,来点真实发生过的事。这一节里每个坑我都尽量把"排查链路"写出来,方便你在遇到类似现象时对号入座。
6.1 自定义字段没声明复制,客户端一直显示默认值
现象:我在 PlayerState 里加了一个KillCount,服务器上打完人立刻能看到数字涨,客户端 UI 死活显示 0。第一反应是 UI 绑定错了,检查了半天绑定没问题。
正确排查顺序是:第一步确认字段在服务器上确实变了(打日志,变了);第二步确认这个 PlayerState 复制到了客户端(PlayerArray 能看到,说明复制了);第三步才去看GetLifetimeReplicatedProps——果然,新增字段没有加DOREPLIFETIME,所以整个 PlayerState 复制过去了,但这个字段没有单独声明。
这里有个非常重要的细节:"Actor 被复制"和"这个 Actor 的某个属性被复制"是两回事。只有写了DOREPLIFETIME或者DOREPLIFETIME_CONDITION的属性才会进复制队列。新手常见的误解是"我的类继承了 PlayerState,里面所有变量应该自动同步吧"——不会的,必须显式声明。
另外,如果这个字段是运行时频繁变化的,记得用ReplicatedUsing = OnRep_Xxx绑定回调去刷新 UI,光靠蓝图里的绑定事件在属性没触发再复制的时候可能不刷新。
6.2 玩家重生之后数据被清空
现象:玩家死亡重生,之前积累的击杀数没了,而队伍总分还在。这说明 GameState 没事,问题出在 PlayerState。
排查后发现两个可能原因。第一种:自己在死亡流程里手动调用了Destroy或者重置 PlayerState。第二种更隐蔽:你把数据存在 Pawn 上,重生时 Pawn 被销毁重建了。Pawn 是每次重生都会换一个实例的,所有挂在 Pawn 上的自定义数据都会归零,这完全符合引擎行为。
解决办法很直接:凡是"跟人绑定而不是跟身体绑定"的数据,一律搬到 PlayerState。这也是为什么装备、技能等级、分数这些字段在成熟的工程里几乎都长在 PlayerState 上。我自己后来养成了一个习惯,在 Pawn 上只放"身体"相关的东西——血条显示、动画状态、当前武器挂点,其他全放 PlayerState。
6.3 无缝切换下 PostLogin 不会再被调用
这个坑最折磨人,因为它在编辑器里测试时可能根本复现不了。现象是:第一张地图一切正常,ServerTravel到第二张地图之后,玩家虽然还在场景里,但技能栏空了、队伍信息没了,而且服务器日志里没有PostLogin的记录。
原因是无缝切换走的是HandleSeamlessTravelPlayer,不是PostLogin。你的初始化逻辑如果只写在PostLogin里,第二张图就不会执行。修法是把公共初始化抽成一个独立函数,PostLogin和HandleSeamlessTravelPlayer都调用它。
同时别忘了检查GetSeamlessTravelActorList,把需要额外保留的自定义 Actor(比如队伍管理器、观众席逻辑)显式加进去。PlayerController 和 PlayerState 默认会被引擎带过图,但自定义的东西不会。这个"默认名单"和"自定义名单"的差别,我当年是靠翻源码确认的,文档里写得比较含糊。
6.4 外接设备与输入映射最终落在谁身上
有朋友问过:外接设备(游戏手柄、飞行摇杆、赛车踏板、自定义按键盒)在 UE4 里到底映射到哪个类。答案是输入这一层最终都落到 PlayerController 上——输入映射表把物理设备的按键/轴映射成 Action 和 Axis,PlayerController 的 InputComponent 接收这些事件,再决定是否转发给当前 Possess 的 Pawn。
这条链路有几个实践上的注意点。第一,输入只在本地玩家所在的端有意义。服务器上没有本地设备输入,它收到的永远是客户端发来的 RPC。所以别在服务器上写"读按键值"的逻辑。第二,外接设备的轴值(比如方向盘转角)通常是带浮点的连续值,从客户端往服务器发的频率要自己控制,不然带宽很浪费——常见做法是本地按 60Hz 采样,但只在值变化超过阈值时才发。第三,多设备同时接入时,PlayerController上可以通过设置InputComponent的优先级或者用自定义的输入预处理来区分是谁在操作,做双人同屏时这两个模式配合很关键。
我自己的项目里做飞行模拟的时候,摇杆的曲线、死区这些参数是存在 GameInstance 里的(因为要持久化到玩家配置),运行时的输入状态挂在 PlayerController 上,而"当前飞机的操纵面偏转"这种结果走物理和复制。三个类各管一段,职责清楚,调试的时候一眼就能定位问题出在哪一层。
最后分享一个我个人的小习惯:当我不确定某个数据该放哪儿的时候,我会先画一张表,横轴是"服务器 / 拥有者客户端 / 其他客户端",纵轴是具体的字段,然后开始填空。填不出来的地方,往往就是设计没说清楚的地方,比写代码的时候才纠结要高效得多。这个五个类的关系,说到底就是"可见性、生命周期、权威性"三个维度交叉出来的结果,把这三条线在心里理直了,绝大多数网络同步的疑难杂症都会变成可解释、可预期的现象。