多人游戏里"我的 Pawn 什么时候才算真的生出来了"这种问题,答错一次就要在生产环境里追一天。基于 UE5.3 源码(Engine/Source/Runtime/Engine/Private/World.cpp、GameFramework/GameModeBase.cpp)把服务端和客户端两条时间线分别走一遍。
服务端:World 创建到玩家落座
World 初始化在 UWorld::InitWorld(World.cpp)。这里有个容易忽略的顺序:GameMode 是 World 在 SetGameMode 里 spawn 的,用的类来自 WorldSettings->DefaultGameMode。也就是说 GameMode 的生存期从 World 初始化就开始了,早于任何玩家连接。
玩家连进来时的调用栈是这条:
UNetDriver 接受连接
→ UWorld::NotifyControlMessage → PreLogin(AGameModeBase::PreLogin)
→ UWorld::SpawnPlayActor → AGameModeBase::Login → PostLogin
SpawnPlayActor(World.cpp 里搜 SpawnPlayActor)做三件关键事:
SpawnActor<APlayerController>(类来自GameMode->PlayerControllerClass);- 调
AGameModeBase::Login给 PlayerController 起名; AGameModeBase::PostLogin(NewPlayer)。
往前一步还有道闸口:PreLogin 收到的是 AGameModeBase::PreLogin,默认实现把转发给了 AGameSession::KickPlayer 之前的满员检查(GameSession->MaxPlayers)。要做人满拒绝、Ban 名单,钩子在 GameSession 的 ApproveLogin/PreLogin 链上,不在 GameMode 里改 Tick 逻辑。被 Kick 的连接在 spawn PC 之前就被掐掉,所以被拒玩家不占 PlayerController 名额。
PostLogin 默认实现里调 HandleStartingNewPlayer_Implementation,它的链路是:
// GameModeBase.cpp(UE5.3)
void AGameModeBase::HandleStartingNewPlayer_Implementation(APlayerController* NewPlayer)
{
if (PlayerCanRestart(NewPlayer)) { RestartPlayer(NewPlayer); }
// ... ChangeName、InitializeHUD 等
}
RestartPlayer 内部:FindPlayerStart 选出生点 → SpawnDefaultPawnFor(用 DefaultPawnClass spawn Pawn)→ Controller->Possess(Pawn)。
Possess 这一棒干的事比名字暗示的多:AController::Possess 里会调 Pawn->PossessedBy(this)(Pawn 侧虚函数,蓝图里对应 Event Possessed),然后 PlayerController->AcknowledgePossession(Pawn) 通过 RPC 通知客户端——客户端的 Pawn 感知是从这个 RPC 开始的,不是从复制开始的。
为什么 Possess 要用 RPC 而不是属性复制
这是理解整套链路的钥匙。UE 的网络层提供两种语义完全不同的同步原语:
- 属性复制是状态语义:只保证"客户端最终收敛到最新值",中间态可以被合并丢弃。一个属性一帧内从 1 变 2 再变 3,客户端可能只收到 3。
- Reliable RPC 是事件语义:在同一个 actor channel 上按序、每次必达,一次都丢不了也乱不了。
"玩家获得了控制权"是一个瞬间事件,不是状态。如果用属性复制表达——比如只复制 Controller 引用——客户端看到引用变了,却不知道这个变化对应"该切输入了"还是别的什么时机语义;更重要的是事件无法被合并丢弃的语义保护。AcknowledgePossession 作为 reliable client RPC 挂在 PlayerController 的 channel 上,和该 channel 上的属性 bunch 严格按序到达。
但跨 channel 就没有顺序保证了——Pawn 是另一个 actor、另一条 channel,它的属性包可能比 AcknowledgePossession 晚到。这不是设计疏漏,而是 UE 复制模型的基本事实:顺序只在单条 channel 内成立。后面客户端一节的 OnRep_Pawn 兜底机制,存在的原因就在这里。
为什么 GameMode 不复制到客户端
客户端没有 GameMode 不是"忘了做",是权威服务器模型的刻意边界:游戏规则(比分判定、刷兵、比赛状态推进)只能有一个裁决者,把规则实体复制到客户端等于把裁决逻辑交给不可信的一端。客户端看到的是规则的结果——GameState 里复制的分数、MatchState 枚举——而不是规则本身。这也是为什么 GameMode 的成员函数几乎都以 HasAuthority() 为隐含前提,在客户端拿到 GameMode 指针恒为 null。
客户端:完全不同的顺序
客户端没有 GameMode(HasAuthority 之外 GameMode 不存在,GameState 才有),时序是:
- Server 通过 ActorChannel 把 PlayerController 复制下来,客户端
SpawnActor出 PC; AcknowledgePossessionRPC 到达,客户端执行AcknowledgePossession;- Pawn 本身通过普通属性复制到达(
Instigator、Controller都是复制属性)。
关键点:客户端上 PlayerController 可能比 Pawn 先到。APlayerController::OnRep_Pawn 就是处理"Pawn 引用后到达"的情况。在客户端 Pawn 的 BeginPlay 里直接 GetController() 拿到 null 是正常的,因为 Controller 属性还没复制过来。正确钩子是重载 APawn::OnRep_Controller。
还有个更隐蔽的:客户端的 AcknowledgePossession 里会 SetPawnFromRep,如果此时 Pawn actor 还没到,就等 OnRep_Pawn 兜底。两个路径都会走到 Pawn->PossessedBy 对应的客户端逻辑。
PlayerState 和 GameState 的位置
APlayerState 是在 AController::InitPlayerState 里 spawn 的(Controller 构造后不久),GameMode 通过 PostLogin 之后它就绪。GameState 由 GameMode 在 InitGameState(GameModeBase.cpp,InitGame 期间)spawn。
所以服务端完整顺序可以背下来:
GameMode → GameState → (玩家连接) PlayerController → PlayerState → Pawn → Possess
客户端则是 GameState 和 Pawn 全靠复制到达,顺序不保证,用 OnRep_* 收口。
AGameMode 多出来的那台状态机
上面讲的都是 AGameModeBase。竞技类游戏用的 AGameMode 在它之上多了一台 MatchState 状态机(GameMode.cpp):WaitingToStart → InProgress → WaitingPostMatch,状态本身就是复制属性,切换靠 SetMatchState,客户端通过 OnRep_MatchState 收到(蓝图里对应 Event On Match State Set)。
这里又是"状态优于事件"的设计选择:MatchState 用复制属性而不是 RPC 广播,原因是晚加入的玩家需要直接拿到当前态。如果状态切换是事件流,中途进房的客户端得靠服务器重放历史事件才能还原现场;用复制属性,新连接的初始 bunch 里就带着当前状态,一步到位。属性复制的"最终收敛"语义对枚举状态机恰好是对的——中间被合并掉的瞬态,客户端本来也不该依赖。
状态机的推进在 AGameMode::Tick 里轮询条件:ReadyToStartMatch(默认等所有玩家 Ready)、ReadyToEndMatch(默认看 GetMatchTime)。这意味着两件事:其一,比赛计时是 GameMode 驱动而非 GameState 自发走的,GameState 里的 ReplicatedWorldTimeSeconds 只是跟随;其二,自定义比赛规则的正确姿势是重载这几个 ReadyTo* 判定函数,而不是在别处轮询分数再去 SetMatchState——状态切换只能发生在服务端 GameMode 这一个权威点上,别处调用会和 Tick 里的轮询打架。
配合状态机的还有一组自动行为:InProgress 进入时触发 HandleMatchHasStarted,它会遍历所有 PlayerController 补一次 RestartPlayer(处理等待期间连进来的玩家)。所以"玩家在等待阶段进房,开赛时才真正拿到 Pawn"是引擎内建行为,不用自己写。
跨地图时谁活下来
Seamless travel(bUseSeamlessTravel)下,AGameMode 会被销毁重建,但 PlayerController、PlayerState 通过 GetSeamlessTravelActorList 进入转移名单活下来。源码里这份名单是 GameMode 给的——默认实现把 GameSession 和每个 PlayerState(附带其 Owner PC)加进去。所以"跨地图带数据"的规范做法是塞进 PlayerState,GameState 则默认重建。非 seamless travel 更简单:一切重来,只有 AGameSession 存活(UWorld::SeamlessTravel 的失败 fallback 路径也走这里)。
这个名单机制背后的判断标准是"谁持有跨地图仍有意义的数据":PlayerController 持有连接句柄(销毁它就断线了),PlayerState 持有玩家身份与战绩,GameState 是当前地图规则的产物所以随地图重建。设计自己的跨地图数据时,先问"这东西属于连接、属于玩家、还是属于这局地图",答案直接决定它该挂在哪里。
实战踩坑记录
- 在 Pawn 的
BeginPlay里访问GetPlayerState():服务端 OK,客户端大概率 null——PlayerState 是复制属性,改用OnRep_PlayerState。 - 想给每个玩家初始化数据,放
GameMode::PostLogin而不是 Pawn 的BeginPlay:后者在流送重载、重连时会反复触发。 FindPlayerStart返回的出生点如果被占用,5.x 里默认会按 tag 和距离依次 fallback;要自定义规则就重载ChoosePlayerStart,别去改RestartPlayer。- 切换 Pawn(如观战模式)用
ChangeState+ChangePawn路径,直接UnPossess/Possess会跳过 GameMode 的状态机(AGameMode的 MatchState 逻辑),竞技类游戏会在这里出状态错乱。
这套链路看着绕,其实每根线都有明确的归属:World 管 spawn,GameMode 管规则,PlayerController 管"谁是玩家"的映射,Pawn 只是最后被套上的躯壳。排问题时先想清楚当前站在哪根线上,再看那一棒的源码,比全局乱翻快得多。