2464 字
12 分钟
UE Gameplay 框架——谁在管理这个游戏

一、一张图看清六个人的关系#

在你 mini-game 的服务端代码里,这几个类就像不同部门的负责人:

服务端(权威) 客户端
┌─────────────────┐ ┌─────────────────┐
│ GameMode │ │ │
│ "规则制定者" │ │ PlayerController│
│ 只存在于服务端 │ │ "玩家的遥控器" │
├─────────────────┤ ├─────────────────┤
│ GameState │──复制到全部→│ GameState │
│ "比赛记分板" │ 客户端 │ (只读副本) │
├─────────────────┤ ├─────────────────┤
│ PlayerState │──复制到所属→│ PlayerState │
│ "个人记分卡" │ 客户端 │ (只读副本) │
├─────────────────┤ ├─────────────────┤
│ Pawn │──复制到全部→│ Pawn │
│ "玩家的肉身" │ 客户端 │ (受控副本) │
└─────────────────┘ └─────────────────┘

核心原则:每个类有自己的职责边界。搞混了边界,代码就会越来越难维护。


二、六个核心类的职责#

1 GameMode ——“比赛规则”#

只存在于服务端。定义了这局游戏的玩法规则。

UCLASS()
class AGameMode : public AInfo {
// 核心职责
void InitGame(...); // 一局开始时调用
void HandleMatchHasStarted();// 比赛正式开始
void HandleMatchHasEnded(); // 比赛结束
void RestartPlayer(...); // 玩家重生/重新加入
AActor* ChoosePlayerStart(...); // 决定在哪 Spawn
// 用哪套类(通过配置指定,不改代码)
TSubclassOf<AGameState> GameStateClass;
TSubclassOf<APlayerController> PlayerControllerClass;
TSubclassOf<APawn> DefaultPawnClass;
TSubclassOf<APlayerState> PlayerStateClass;
};

GameMode 是”今天玩什么游戏”——换一个 GameMode,换一套规则。CTF(夺旗)和 DeathMatch(死斗)的 GameMode 完全不同。

在你的竞拍游戏里,GameMode 应该管的事情:

  • 这局几轮(最多 5 轮)
  • 每轮的时间限制
  • 判定谁是赢家的条件(总资产最高)
  • 什么时候进入情报阶段、什么时候进入出价阶段

你容易犯的错:把太多东西塞进 GameMode。如果 GameMode 里出现了”处理开箱动画”的代码,就是放错了——那是 Pawn 或 PlayerController 该管的。

2 GameState ——“记分板”#

服务端 + 复制到所有客户端。存储所有人都需要知道的公共状态。

UCLASS()
class AGameState : public AInfo {
UPROPERTY(Replicated)
TArray<APlayerState*> PlayerArray; // 所有玩家
UPROPERTY(Replicated)
float MatchStartTime; // 比赛开始时间
UPROPERTY(Replicated)
float RemainingTime; // 剩余时间
UPROPERTY(Replicated)
bool bIsBiddingPhase; // 当前是否是出价阶段
};

判断一个数据该不该进 GameState 的标准所有人都需要知道它吗?

在你竞拍游戏里:

  • ✅ 当前轮次、剩余时间 → GameState
  • ✅ 本轮已出价玩家数量(不暴露具体出价金额) → GameState
  • ✅ 上轮结算结果(公开信息) → GameState
  • ❌ 某个玩家的当前出价金额(暗拍!不能让别人知道) → 不出现在 GameState,服务端只在 Reveal 阶段下发

3 PlayerState ——“个人记分卡”#

服务端 + 复制到该玩家客户端。存储单个玩家的”账号级”状态。

UCLASS()
class APlayerState : public AInfo {
UPROPERTY(Replicated)
FString PlayerName; // 玩家名
UPROPERTY(Replicated)
int32 Score; // 得分/总资产
UPROPERTY(Replicated)
int32 Ping; // 延迟
UPROPERTY(Replicated)
bool bIsSpectator; // 是否观战
};

在你竞拍游戏里:

  • ✅ 玩家总资产 → PlayerState
  • ✅ 玩家是否在线(掉线状态) → PlayerState
  • ✅ 玩家角色(使用哪个角色,技能是什么) → PlayerState
  • ❌ 藏馆里的具体藏品列表(自己的藏品只有自己需要) → 服务端存,按需发给客户端,不一定放 PlayerState

4 PlayerController ——“玩家的遥控器”#

每个玩家一个。客户端持有一个(可以发送 RPC)、服务端持有一个(权威)。

它是玩家和游戏世界之间的翻译官:

  • 接收输入(按键、鼠标)
  • 翻译为游戏操作(RPC → 服务端执行)
  • 管理相机(ViewTarget)
  • 管理 UI(HUD)
  • 处理网络同步(它是控制通道)
UCLASS()
class APlayerController : public AController {
// 控制的对象
APawn* AcknowledgedPawn;
// 输入处理
virtual void SetupInputComponent();
// 相机
void SetViewTarget(AActor* NewTarget);
// RPC 通道
UFUNCTION(Server, Reliable)
void Server_PlaceBid(int32 BidAmount);
};

在你竞拍游戏里,PlayerController 应该管的事:

  • 玩家的 UI 输入(按下出价按钮 → 触发 Server_PlaceBid RPC)
  • 相机控制(竞拍桌面的视角、开箱时的特写)
  • 玩家掉线/重连的通知

5 Pawn / Character ——“玩家的肉身”#

被 PlayerController 控制、在世界上有物理存在的对象

UCLASS()
class APawn : public AActor {
APlayerController* Controller; // 谁控制我
virtual void PossessedBy(AController* NewController); // 被控制时
virtual void UnPossessed(); // 被放弃时
// 移动(如果是 Character——加了 UCharacterMovementComponent 的子类)
virtual void AddMovementInput(FVector Direction, float Scale);
};

在你的竞拍游戏里——如果不是第一人称 3D 走路的游戏,可能没有传统意义上的 Pawn。竞拍桌面 + UI 模式里,Pawn 可能只是一个空壳(放相机用),或者根本用不到。Gameplay 框架是弹性的,不需要 Pawn 的场合就别硬用。

6 PlayerController 和 Pawn 的区别#

经常有新人困惑。一句话:

PlayerController 是”玩家”,Pawn 是”玩家在游戏世界里的身体”。 玩家断线了——PlayerController 还在(等待重连),Pawn 被 Destroy。 玩家换角色——PlayerController 不变,Possess 一个新的 Pawn。


三、初始化顺序:一个客户端加入时发生了什么#

理解这个能帮你 debug 很多”为什么这个时候我拿不到 GameState”的问题。

1. 客户端连接 → 服务端创建 PlayerController
2. PlayerController::BeginPlay (服务端)
3. GameMode::Login → 创建 PlayerState
4. PlayerController::Possess → 创建/控制 Pawn
5. 复制 GameState 到客户端
6. 复制 PlayerState 到客户端
7. 复制 Pawn 到客户端
8. PlayerController::BeginPlay (客户端) ← 此时 GameState 已可用
9. Pawn::BeginPlay (客户端)

常见坑:在 BeginPlay 里用 GetGameState(),在客户端可能拿不到。因为 GameState 的复制可能晚于你的 BeginPlay。安全做法:

void AMyPlayerController::BeginPlay() {
Super::BeginPlay();
if (GetGameState()) {
// 可能为空!GameState 的复制可能还没到达
}
}
// 正确做法:覆盖 ReceivedGameState
void AMyPlayerController::ReceivedGameState() {
Super::ReceivedGameState();
// 这里保证 GameState 已经到了
}

四、用在你竞拍游戏里的设计练习#

假设给你的竞拍游戏做一次”类职责分配”练习:

需求该放在哪为什么
轮次计时器、轮次切换GameMode(服务端)规则制定者
当前轮次、倒计时显示GameState(Replicated)所有人都要看到
玩家出价金额(暗拍阶段)PlayerController 在服务端的 Server_PlaceBid 里存储不能让客户端知道别人的出价
玩家总资产PlayerState(Replicated)其他玩家也要看到排名
技能使用(索菲:揭示两个藏品品质)GameMode 或一个独立的 BidAbilityComponent这是技能系统的范畴,见下一节 GAS
开箱演出触发PlayerController(Multicast RPC 或服务端触发客户端)演出是纯客户端表现
藏馆 3D 展示客户端 Local 逻辑展示不需要网络同步
掉线玩家托管出价GameMode(服务端)规则制定者

这种”给需求找主人”的思维练习,就是在训练你作为 Gameplay 程序员的架构能力。


五、GAS——用技能系统做复杂的 Gameplay 逻辑#

1 GameplayAbilitySystem 是什么#

GAS 是 UE 官方提供的一套用来做技能、属性、Buff 的框架。它不是引擎内置类型——而是在 UE 源码的 Plugins/Runtime/GameplayAbilities 下,需要手动启用。

很多游戏项目的技能系统是在 GameMode/Actor 里硬写 if-else,最后变成几千行的上帝类。GAS 解决的问题就是把”谁会什么技能、技能有什么效果、效果怎么叠加”从具体 Actor 里解耦出来

2 核心概念#

AbilitySystemComponent (ASC)
├── GameplayAbilities (技能——具体"做什么")
│ ├── 出价技能 → 使用后可以让本轮出价获得折扣
│ ├── 情报技能 → 揭示某个藏品的品质
│ └── 干扰技能 → 给对手的出价区间提供假情报
├── GameplayEffects (效果——"状态变化")
│ ├── 增加资产
│ ├── 本轮出价被冻结(不能出价)
│ └── 下一轮获得额外情报
├── GameplayTags (标签——"你是谁、你身上有什么状态")
│ ├── Character.Sofia (索菲)
│ ├── Status.Phase.Intel (情报阶段)
│ └── Buff.NextRound.DoubleInfo (下轮双倍情报)
└── GameplayAttributes (属性——"你的数值")
├── TotalAsset (总资产)
├── BidDiscount (出价折扣)
└── InfoCount (本期可获得的情报数量)

3 为什么用 GAS 而不是自己写#

自己写GAS
技能效果散落在各处GameplayEffect 统一描述”属性和标签的状态变化”
Buff/Debuff 需要手动计时和清理GE Duration 策略(Instant / HasDuration / Infinite)自动管理
网络同步自己写 RPCGAS 内置 Prediction(客户端预测技能效果,服务端验证)
技能之间的交互(免疫、覆盖)写 if-elseGameplayTag 查询——“有没有这个 Tag?有就不能释放”

4 你的竞拍游戏里用 GAS 合适吗#

坦白说,竞拍类游戏的技能系统相对简单(一个角色只有 1-2 个技能),GAS 有点重。但它值得你花时间学习,原因是:

  • 你的导师让你看 Gameplay,GAS 是 UE Gameplay 方向的必修课
  • 很多商业项目用 GAS(包括网易的一些 UE 项目),面试可能会问
  • 即使不用全套 GAS,它的设计思想(把 GameplayEffect 看成”状态变化的事务”、把 GameplayTag 看成”状态查询的索引”)值得在你的任何技能系统里采用

六、总结#

一句话在哪存在
GameMode比赛规则,换玩法就换它仅服务端
GameState公开记分板,所有人都能看服务端 + 复制到所有客户端
PlayerState个人记分卡,跨 Pawn 保持服务端 + 复制到所属客户端
PlayerController玩家的遥控器,输入→操作服务端 + 客户端各一个
Pawn玩家的身体,在世界上有位置服务端 + 复制
ASC (GAS)技能/属性/效果的管理中心通常在 PlayerState 或 Pawn 上

搞清这六个类的职责边界,是写好 UE Gameplay 代码的第一步。大多数”为什么这个值读不到”、“为什么客户端看不到这个变化”的 bug,根因都在这六个类谁该拥有什么数据上。

UE Gameplay 框架——谁在管理这个游戏
https://www.m4doka.xyz/posts/engine/engine-8-gameplay-framework/
作者
m4doka
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0