UE5.8 中的 GameMode、GameState 与 PlayerState

📑 目录

引子:记分牌上的乌龙

小林所在的小团队正在用 UE5.8 做一款 5v5 英雄射击游戏《Neon Arena》。地图是一张未来都市的中庭,两队各五名角色争夺据点。第一次内部联机测试时,大家发现记分牌总是慢半拍:A 队已经拿到 300 分,客户端却还在显示 298;一名玩家被爆头后,他自己的屏幕已经黑掉,但队友看他的角色还在原地抽搐。最离谱的是有人退出再进,击杀数变成了零。

策划拍桌子:分数必须所有人同时看到,死亡后立刻切视角,退出重连不能丢数据。主程老陈打开工程,把三个类的关系图画在白板上:GameMode 管规则,GameState 管全场状态,PlayerState 管玩家档案。大部分混乱都来自没分清这三件事。

GameMode:只存在于服务器里的裁判

GameMode 是游戏规则的权威执行者。它只在服务器上存在,客户端不会生成它的实例,所以任何涉及胜负、重生、配队的判断都应该写在这里,而不是 HUD 或者 Pawn 里。

在《Neon Arena》中,GameMode 负责:

  • 决定本局地图、队伍人数、胜利条件。
  • 玩家加入时为每个人生成 PlayerController 和 PlayerState。
  • 玩家死亡后启动重生计时器。
  • 比赛时间到或者某队达到目标分数时结束本局。

因为客户端没有 GameMode,策划一直念叨的“本地判胜”是不可能直接实现的。所有规则都必须由服务器裁定,再通过网络复制把结果告诉客户端。这个设计一开始就排除了很多作弊可能。

生命周期

GameMode 的生命周期由引擎在关卡加载后自动驱动。下面这张图展示了主要阶段。

flowchart TD
    A[关卡加载完成] --> B[InitGame]
    B --> C[PreLogin 验证]
    C --> D[PostLogin 生成 PlayerController]
    D --> E[StartMatch]
    E --> F[Tick 更新规则]
    F --> G{满足结束条件?}
    G -->|否| F
    G -->|是| H[EndGame]
    H --> I[切换地图或回到大厅]
  • InitGame 读取地图配置,设置默认 Pawn、HUD、PlayerState 等类。
  • PreLogin 可以做连接校验,例如封禁列表、版本号检查。
  • PostLogin 生成 PlayerController,随后 GameMode 调用 RestartPlayer 生成 Pawn。
  • StartMatch 是比赛正式开始的节点,适合在这里广播初始数据。
  • EndGame 触发胜负结算,但真正的地图切换通常由 GameSession 或 Travel 负责。

下面的头文件展示了一个最简自定义 GameMode:

#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "NeonArenaGameMode.generated.h"

UCLASS()
class NEONARENA_API ANeonArenaGameMode : public AGameModeBase
{
    GENERATED_BODY()

public:
    ANeonArenaGameMode();

    virtual void InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage) override;
    virtual void PostLogin(APlayerController* NewPlayer) override;
    virtual void Logout(AController* Exiting) override;
    virtual void HandleMatchHasStarted() override;
    virtual void HandleMatchHasEnded() override;

    UFUNCTION()
    void OnPlayerKilled(AController* Killer, AController* Victim);

    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    int32 ScoreLimit = 500;

    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    float RespawnDelay = 3.0f;
};

这个类不超过五十行,但已经覆盖了生命周期钩子、击杀回调和两个核心规则参数。服务器在运行时会持有这个类的唯一实例,所有关键判断都在这里发生。

专用服务器与监听服务器的差异

专用服务器没有本地玩家,GameMode 的所有判断都基于远程连接。监听服务器上,房主自己也是一个玩家,Role 同样是 Authority,所以代码不需要额外分支。区别在于监听服务器会同时运行 Pawn 和 PlayerController,调试时更容易混淆哪些逻辑跑了两次。小林团队在 PIE 里用两个客户端加一个监听服务器测试,发现死亡事件在房主本地只触发一次,在其他客户端也没有重复,说明 GameMode 的权威判断是可靠的。

GameState:全场都能看见的公告板

如果说 GameMode 是后台裁判,GameState 就是挂在场馆中央的大屏幕。它存储所有玩家都需要知道的信息:两队当前分数、比赛剩余时间、当前回合、占领进度等。GameState 会在服务器生成,然后复制到所有连接的客户端。

在《Neon Arena》里,GameState 上加了这些字段:

UCLASS()
class NEONARENA_API ANeonArenaGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    UPROPERTY(ReplicatedUsing = OnRep_TeamScore, BlueprintReadOnly, Category = "Score")
    int32 TeamAScore = 0;

    UPROPERTY(ReplicatedUsing = OnRep_TeamScore, BlueprintReadOnly, Category = "Score")
    int32 TeamBScore = 0;

    UPROPERTY(Replicated, BlueprintReadOnly, Category = "Time")
    float RemainingTime = 600.0f;

    UPROPERTY(Replicated, BlueprintReadOnly, Category = "State")
    int32 CurrentRound = 1;

    UFUNCTION()
    void OnRep_TeamScore();

    void AddScoreForTeam(int32 TeamID, int32 Delta = 1);
};

字段加上 Replicated 后,服务器修改数值,客户端会自动收到更新。ReplicatedUsing 在分数变化时调用 OnRep_TeamScore,方便 HUD 播放分数跳动动画。HUD 只需要每帧从本地 GameState 读取,不需要自己发网络请求。这就解释了为什么策划要求“所有人同时看到分数”——数据源头只有一个,就是服务器上的 GameState。

architecture-beta
    group server[服务器]
        service gamemode[GameMode] in server
        service gamestate[GameState] in server
        service playerstate[PlayerState] in server
    group clientA[客户端 A]
        service ahud[HUD] in clientA
    group clientB[客户端 B]
        service bhud[HUD] in clientB

    gamemode:R --> L:gamestate
    gamestate:R --> L:ahud
    gamestate:R --> L:bhud
    playerstate:R --> L:ahud

这张图里,GameMode 只留在服务器;GameState 和 PlayerState 的数据被复制到客户端,供 HUD 显示。客户端永远不能直接修改 GameState,否则就会出现“本地改了分数但服务器不承认”的闹剧。

GameState 还有一个容易被忽略的作用:它保存了 PlayerArray,里面是所有在线玩家的 PlayerState 引用。想做一个全场排行榜,遍历 GameState 的 PlayerArray 就够了。下面这个片段演示了如何按击杀数排序:

TArray<ANeonArenaPlayerState*> ANeonArenaGameState::GetSortedPlayerStates()
{
    TArray<ANeonArenaPlayerState*> Result;
    for (APlayerState* PS : PlayerArray)
    {
        if (ANeonArenaPlayerState* NAPS = Cast<ANeonArenaPlayerState>(PS))
        {
            Result.Add(NAPS);
        }
    }
    Result.Sort([](const ANeonArenaPlayerState& A, const ANeonArenaPlayerState& B)
    {
        return A.Kills > B.Kills;
    });
    return Result;
}

这个函数在服务器和客户端都能跑,因为 PlayerArray 本身会被复制到所有客户端。排行榜的数据来源仍然是服务器写好的 PlayerState。

PlayerState:跟着玩家走的数据档案

PlayerState 记录单个玩家的持久数据。Pawn 会死、会重生、会被销毁,但 PlayerState 在整局比赛中一直存在。击杀、死亡、助攻、金钱、经验、ping、所属队伍,都应该放在这里。

在《Neon Arena》中,每个玩家进入服务器时,GameMode 会为他创建一个 PlayerState。这个 PlayerState 会复制给所有客户端,所以敌方玩家也能看到你的击杀数和 ping。同时它也会复制回本地客户端,确保你自己的 HUD 数据正确。

UCLASS()
class NEONARENA_API ANeonArenaPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    UPROPERTY(ReplicatedUsing = OnRep_Kills, BlueprintReadOnly, Category = "Stats")
    int32 Kills = 0;

    UPROPERTY(ReplicatedUsing = OnRep_Deaths, BlueprintReadOnly, Category = "Stats")
    int32 Deaths = 0;

    UPROPERTY(ReplicatedUsing = OnRep_Assists, BlueprintReadOnly, Category = "Stats")
    int32 Assists = 0;

    UPROPERTY(Replicated, BlueprintReadOnly, Category = "Team")
    int32 TeamID = 0;

    UFUNCTION()
    void OnRep_Kills();
    void OnRep_Deaths();
    void OnRep_Assists();

    void AddKill();
    void AddDeath();
    void AddAssist();
};

ReplicatedUsing 会在数值变化时触发回调函数,适合在 HUD 里播放加分动画。如果只是 Replicated,客户端也能得到值,但变化瞬间不会有事件通知。小林在实现连杀提示时,就是在 OnRep_Kills 里检查连续击杀数,然后让本地 HUD 弹出图标。

PlayerState 的另一个关键是它会参与比赛结束后的结算。Pawn 在 EndGame 时大概率已经被销毁,但 PlayerState 还在,所以赛后展示 MVP 必须读取 PlayerState 里的数据。如果把击杀数存在 Pawn 上,比赛一结束就全没了。

还有一点:不要把玩家输入相关的状态放到 PlayerState。移动方向、瞄准角度、当前武器槽位这些高频变化的数据更适合放在 Pawn 或 PlayerController 里。PlayerState 适合放置整局内变化不频繁、但又需要展示给所有人的数据。

三者的网络角色与复制关系

多人游戏里,每个 Actor 都有 GetLocalRole() 返回的权限角色:Authority、SimulatedProxy、AutonomousProxy。GameMode、GameState、PlayerState 都是 Actor 派生类,因此也遵循同一套规则。

  • GameMode 只在服务器存在,Role 永远是 Authority。客户端获取 GameMode 会得到 nullptr,所以不要在客户端写 GetWorld()->GetAuthGameMode()
  • GameState 在服务器是 Authority,在所有客户端是 SimulatedProxy。它通过属性复制把数据推送到每个客户端。
  • PlayerState 也在服务器是 Authority,在客户端是 SimulatedProxy。它同时属于某个 PlayerController,Owner 关系让客户端可以拿到自己的 PlayerState,也能看到其他人的。

下面这张图把复制方向画了出来。

graph LR
    S[服务器]
    C1[客户端 A]
    C2[客户端 B]

    GM[GameMode 仅服务器]
    GS[GameState]
    PS[PlayerState]

    S -->|拥有| GM
    S -->|复制| GS
    GS -->|复制到| C1
    GS -->|复制到| C2
    S -->|复制| PS
    PS -->|复制到| C1
    PS -->|复制到| C2

复制频率由 NetUpdateFrequency 控制。GameState 通常不需要每帧更新,可以设成 10 到 20 Hz;PlayerState 的击杀死亡变化不频繁,也可以设低一点。UE5.8 默认使用条件复制和推模型,只有真正变化的属性才会占用带宽。

对于数值同步,可以简单理解为:服务器上的真实值 VserverV_{server} 在客户端呈现为 VclientV_{client},两者之间存在网络延迟 Δt\Delta t。理想情况下满足:

Vclient(t)=Vserver(tΔt)V_{client}(t) = V_{server}(t - \Delta t)

实际游戏中还有插值和预测,所以客户端看到的分数变化会晚几十到上百毫秒。这个延迟对于 HUD 分数是可以接受的,对于命中的判定就必须交给服务器上的 GameMode 处理。

客户端如果想请求服务器执行规则,不能调用 GameMode 的函数,因为客户端根本没有这个对象。正确做法是发一个运行在 PlayerController 上的 Server RPC,由 PlayerController 把请求转发给 GameMode。PlayerController 本身在服务器也存在,所以这条路是通的。

击杀、死亡与重生流程

回到《Neon Arena》的联机测试。一名玩家被爆头时,数据流大致如下:受害者的 Pawn 收到伤害,生命值降到零;Pawn 通知 GameMode 自己被击杀;GameMode 找到杀手和受害者的 PlayerState,分别更新 Kills 和 Deaths;GameState 根据队伍规则更新 TeamScore;最后 GameMode 启动重生定时器,几秒后给受害者生成新的 Pawn。

sequenceDiagram
    participant Attacker as 攻击方 Pawn
    participant Victim as 受害方 Pawn
    participant GM as GameMode
    participant VPS as 受害者 PlayerState
    participant APS as 攻击方 PlayerState
    participant GS as GameState
    participant Respawn as 重生计时器

    Attacker->>Victim: ApplyDamage
    Victim->>Victim: Health <= 0
    Victim->>GM: NotifyKilled
    GM->>VPS: AddDeath
    GM->>APS: AddKill
    GM->>GS: AddTeamScore
    GM->>Respawn: SetTimer
    Respawn-->>GM: 时间到
    GM->>Victim: RestartPlayer

这个流程里有几个常见坑。

第一,不要在 Pawn 里直接改分数。Pawn 是客户端和服务端都有的对象,客户端 Pawn 被攻击时也会收到伤害事件,如果在这里加死亡数,就会出现“本地以为自己死了好几次”的情况。死亡判定必须只在服务器 GameMode 里做。

第二,重生时不要立即销毁旧 Pawn。立刻销毁会导致视角抖动。更稳妥的做法是让旧 Pawn 播放死亡动画,然后调用 RestartPlayer,引擎会在新的 PlayerStart 上生成新 Pawn 并重新 possess。

第三,如果 PlayerState 在重生前被意外销毁,击杀数就会清零。GameMode 默认会在 Logout 时处理 PlayerState,但只要玩家还在同一局游戏里,就不应该手动销毁它。退出重连后重新加入,GameMode 会新建 PlayerState,所以老数据需要靠持久化存储来恢复,例如 GameInstance 或者在线服务。

GameMode 里的一段典型实现如下:

void ANeonArenaGameMode::OnPlayerKilled(AController* Killer, AController* Victim)
{
    if (!HasAuthority()) return;

    if (ANeonArenaPlayerState* VictimPS = Victim->GetPlayerState<ANeonArenaPlayerState>())
    {
        VictimPS->AddDeath();
    }

    if (Killer && Killer != Victim)
    {
        if (ANeonArenaPlayerState* KillerPS = Killer->GetPlayerState<ANeonArenaPlayerState>())
        {
            KillerPS->AddKill();
        }
    }

    if (ANeonArenaGameState* GS = GetGameState<ANeonArenaGameState>())
    {
        GS->AddScoreForTeam(GetTeamOf(Victim));
    }

    FTimerHandle Handle;
    FTimerDelegate Delegate;
    Delegate.BindUFunction(this, FName("RestartPlayer"), Victim);
    GetWorldTimerManager().SetTimer(Handle, Delegate, RespawnDelay, false);
}

这段代码同样不超过五十行,但覆盖了击杀记录、队伍得分和重生定时器。HasAuthority() 作为防御性检查,可以防止客户端 RPC 被恶意调用时直接修改数据。

如果玩家自杀,Killer 和 Victim 指向同一个 Controller,上面代码会跳过加击杀,但仍然加死亡。策划后来补充:自杀扣队伍一分。这个逻辑同样写在 GameMode 里,只改 TeamScore,不影响玩家个人统计。

常用配置与 C++ 示例

项目启动时,GameMode、GameState、PlayerState 的默认类可以在 DefaultEngine.ini 里配置,也可以在 World Settings 里按地图覆盖。

[/Script/Engine.Engine]
+DefaultGameMode=/Script/NeonArena.NeonArenaGameMode

[/Script/Engine.WorldSettings]
DefaultGameMode=/Script/NeonArena.NeonArenaGameMode

[/Script/NeonArena.NeonArenaGameMode]
GameStateClass=/Script/NeonArena.NeonArenaGameState
PlayerStateClass=/Script/NeonArena.NeonArenaPlayerState
DefaultPawnClass=/Script/NeonArena.NeonArenaCharacter

推荐在 C++ 构造函数里设置这些类,这样蓝图子类继承时也能沿用:

ANeonArenaGameMode::ANeonArenaGameMode()
{
    GameStateClass = ANeonArenaGameState::StaticClass();
    PlayerStateClass = ANeonArenaPlayerState::StaticClass();
    DefaultPawnClass = ANeonArenaCharacter::StaticClass();
    PlayerControllerClass = ANeonArenaPlayerController::StaticClass();
    HUDClass = ANeonArenaHUD::StaticClass();
}

想要快速获取当前 GameState,可以用 GetWorld()->GetGameState<ANeonArenaGameState>()。获取本地 PlayerState 则通过 GetPlayerState<ANeonArenaPlayerState>()。如果要在客户端读取队友数据,遍历 GameState->PlayerArray 即可。

对于分数相关的简单计算,比如个人贡献分 PP,可以用击杀、死亡和助攻的加权公式:

P=100K+50A25DP = 100K + 50A - 25D

其中 KK 是击杀数,AA 是助攻数,DD 是死亡数。这个公式没有绝对公平,只是《Neon Arena》策划拍板的初始版本。单次助攻奖励 5050 分,每次死亡扣除 2525 分,因此高击杀低死亡玩家的 PP 会显著更高。行内也可以写:假设一名玩家 K=8K=8A=3A=3D=2D=2,那么 P=100×8+50×325×2=950P = 100 \times 8 + 50 \times 3 - 25 \times 2 = 950

UE5.8 带来的变化

UE5.8 在游戏框架层面并没有推翻这三驾马车,但做了很多让网络同步更稳、更可控的工作。

最值得一提的是 Iris 复制系统正式进入 Production-Ready 状态。Iris 把复制数据和游戏线程解耦,对大量 PlayerState 和 GameState 属性的场景更友好。开启 Iris 后,复制系统的并发度和带宽利用率都有提升,适合《Neon Arena》这种 10 人对战、每人 PlayerState 上又挂了不少统计字段的项目。

Iris 的 NetObjectFilter 还能按队伍、距离做动态过滤。如果你的游戏只想让 PlayerState 复制给同队玩家,可以设置 Connection Filter。这比在旧复制系统里写 Replication Graph 节点要轻量很多。

其次是 Modular Gameplay 和 Game Features 插件。团队可以把“据点占领模式”做成一个独立 Game Feature,启动时才挂载对应的 GameMode 扩展组件。这样基础 TDM 和占领模式可以共用同一套 PlayerState 和 GameState,规则逻辑放在 Feature 里按需加载,仓库结构会更干净。

StateTree 和 Mover 组件在 5.8 里也有 networking 相关的改进。如果你把 AI 机器人的状态机或者移动同步逻辑拆到 StateTree / Mover,复制路径会比传统 CharacterMovementComponent 更清晰。这对以后扩展 spectator 模式或者 AI 填充对战都留了空间。

另外,5.8 的编辑器工具链更新更快,cook 时间缩短。对于需要频繁测试多人流程的团队来说,这减少了反复打包验证 GameMode 规则的时间。

GameSession 与 Travel:容易被忽略的幕后角色

GameMode 不是一个人在战斗。它内部还持有一个 GameSession,负责连接管理、玩家上限、会话 banning。平时写游戏逻辑很少直接碰 GameSession,但如果你要做自定义匹配限制或者旅行迁移,就必须知道它的存在。

地图切换 travel 时,GameMode 和 GameState 都会被销毁,GameInstance 会保留。所以跨关卡的数据,比如玩家背包、匹配房间号,应该存在 GameInstance 而不是 GameState。PlayerState 也会随关卡切换销毁,只有在线服务的存档才能让它重生。

小林团队在一次实现大厅到战斗地图的切换时,把玩家选的英雄存在 GameState 里,结果进入新地图后发现选角全丢了。后来改存 GameInstance,问题才消失。这个教训说明:生命周期比类名更重要。

蓝图中的对应节点

即使项目主要用 C++,策划和 UI 程序也常在蓝图里读取数据。获取 GameState 可以用 Get Game State 节点,获取 PlayerState 用 Get Player State。客户端蓝图里不要尝试 Set 这些值,否则会编译通过但运行时不生效,因为本地没有 Authority。

如果蓝图需要通知服务器改规则,应该调用 PlayerController 上的 Server RPC。这个 RPC 在服务器端执行,再由服务器去修改 GameState 和 PlayerState。把这条链路写清楚,能避免很多“蓝图改不动数值”的困惑。

调试经验

调试 GameMode 最简单的方法是打开专用服务器。PIE 里的监听服务器虽然方便,但容易把本地玩家和服务器逻辑混在一起。打包一个独立的 Server.exe,然后用两个客户端连接,能更快定位问题。

常用命令有三个。stat net 看网络流量,确认 GameState 和 PlayerState 的复制频率。showdebug playerstate 在屏幕上显示当前 PlayerState 字段。net.Role 可以在运行时打印 Actor 的角色,判断代码是不是跑在了错误的地方。

如果 HUD 上的分数不同步,优先检查服务器上的 GameState 是否真的改了值。客户端 HUD 显示错误,多数情况下是复制方向搞反,或者是客户端在本地又做了一次加减。不要试图在客户端修正数据,而是回到服务器找源头。

小林有一次在 Widget 里多写了一条本地累加分数的线,导致所有客户端都觉得自己队伍领先。删掉那条线,只从 GameState 读取,问题立刻消失。这个案例再次验证:权威只在服务器。

一个反直觉的细节

有些新手会把当前生命值也放到 PlayerState,觉得它是玩家状态。其实生命值属于 Pawn,因为每次重生都会重置满血,而 PlayerState 的死亡数不会重置。把会随 Pawn 生命周期变化的数据塞进 PlayerState,会导致重生后老数据还在,或者出现玩家没死但分数被扣的奇怪现象。

另一个容易搞混的是 ping 和延迟。ping 由引擎自动维护,存在 PlayerState 里,可以直接读取。网络延迟补偿则属于 GameMode 或武器系统的职责,不要把它和 PlayerState 混为一谈。分清楚哪些数据随 Pawn 消失,哪些数据随玩家整局保留,是写好这三者的第一步。

小结

GameMode、GameState、PlayerState 的分工可以概括为:GameMode 定规则,GameState 存全局,PlayerState 存个人。服务器是唯一的真相来源,客户端只负责展示复制过来的数据。只要坚持这个原则,《Neon Arena》的记分牌延迟、死亡不同步、退出丢数据这些问题都能避免。

开发过程中,最常犯的错误是把规则逻辑写到客户端 Pawn 或 Widget 里。牢记:GameMode 只在服务器;GameState 和 PlayerState 复制到所有客户端;Pawn 死了可以重建,PlayerState 要跟着玩家活完整局。把这三点刻在肌肉记忆里,联机调试会少很多深夜加班。