技术博客

GameMode/PlayerController/Pawn 初始化链路:时序源码走读

第 20 / 100 篇 · 2026-06-21 · ue5 · GameFramework源码分析生命周期

多人游戏里"我的 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)做三件关键事:

  1. SpawnActor<APlayerController>(类来自 GameMode->PlayerControllerClass);
  2. 调 AGameModeBase::Login 给 PlayerController 起名;
  3. 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 才有),时序是:

  1. Server 通过 ActorChannel 把 PlayerController 复制下来,客户端 SpawnActor 出 PC;
  2. AcknowledgePossession RPC 到达,客户端执行 AcknowledgePossession;
  3. 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 对应的客户端逻辑。

服务端与客户端的初始化时序:两条完全不同的路径 服务端(Authority) 客户端(Simulated) InitWorld → GameMode InitGameState → GameState SpawnPlayActor → PC InitPlayerState RestartPlayer → Pawn Possess PC 复制到达,本地 Spawn AcknowledgePossession RPC Pawn 复制到达(可能更晚) OnRep_Pawn / OnRep_Controller ActorChannel 复制(PC channel 内有序) reliable RPC,与 PC channel 按序 Pawn 走自己的 channel,跨 channel 无序 顺序保证只在单条 channel 内成立 → 客户端一切时序依赖用 OnRep_* 收口

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 只是最后被套上的躯壳。排问题时先想清楚当前站在哪根线上,再看那一棒的源码,比全局乱翻快得多。