一、一张图看清六个人的关系
在你 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_PlaceBidRPC) - 相机控制(竞拍桌面的视角、开箱时的特写)
- 玩家掉线/重连的通知
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. 客户端连接 → 服务端创建 PlayerController2. PlayerController::BeginPlay (服务端)3. GameMode::Login → 创建 PlayerState4. PlayerController::Possess → 创建/控制 Pawn5. 复制 GameState 到客户端6. 复制 PlayerState 到客户端7. 复制 Pawn 到客户端8. PlayerController::BeginPlay (客户端) ← 此时 GameState 已可用9. Pawn::BeginPlay (客户端)常见坑:在 BeginPlay 里用 GetGameState(),在客户端可能拿不到。因为 GameState 的复制可能晚于你的 BeginPlay。安全做法:
void AMyPlayerController::BeginPlay() { Super::BeginPlay(); if (GetGameState()) { // 可能为空!GameState 的复制可能还没到达 }}
// 正确做法:覆盖 ReceivedGameStatevoid 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)自动管理 |
| 网络同步自己写 RPC | GAS 内置 Prediction(客户端预测技能效果,服务端验证) |
| 技能之间的交互(免疫、覆盖)写 if-else | GameplayTag 查询——“有没有这个 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,根因都在这六个类谁该拥有什么数据上。