凌晨两点,策划在群里发消息:测试版里玩家死了就再也活不过来,重生按钮是灰的。排查两小时,病根只有一行——新人把重生倒计时写在了 Pawn 的 Tick 里。角色一死,Pawn 销毁,计时器跟着进坟,再也没人通知玩家复活。UE 新手的诡异 bug,九成不怪引擎,怪代码放错了类。GameMode、GameState、PlayerController、PlayerState、Pawn、Character、HUD 这七件套,每个都有自己的地盘。这章把地盘划清楚。
本章地图
mindmap
root((Gameplay 框架七件套))
两份账本
GameMode 记规则
GameState 记事实
联机时 GameMode 只在服务器
手与身体
PlayerController 输入与镜头
Pawn 可被附身的实体
Character 人形全家桶
带得走的身份证
PlayerState 击杀 队伍 昵称
面子工程
HUD 只当 UMG 挂载点
UI 拉数据 不存数据
落地
归属决策树 11 个场景
单机与联机驻留对比
回响峡谷落位表一句话记住全章:GameMode 管规则,GameState 管事实;PlayerController 是手,Pawn 是身体。剩下的三个类,都是这两句话的推论。
一局游戏有两份账本:GameMode 记规则,GameState 记事实
从其他引擎转来的人常有疑问:为什么不像某些引擎那样,一个 GameManager 单例统管全局?答案在"上帝类"这三个字里。单例管一切,三个月后这个类就会长到三千行,规则、数据、输入、界面缠成一团,改一行计分逻辑带出三个重生 bug。UE 的做法是预先把一局游戏拆成七个职责明确的类,每个类的地盘用继承体系固定下来——框架替你做了一半的架构决策,代价是你得先搞清楚这七块地盘分别在哪。
先把类比砸实:一场球赛里,GameMode 是裁判兼规则书,GameState 是场边的记分牌。
裁判知道什么叫越位、什么时候吹终场哨,但裁判不进场踢球;记分牌上写着比分和剩余时间,所有人抬头就能看,但记分牌决定不了任何事。规则是"判定逻辑",事实是"公共数据",两者分开存放,这是 UE Gameplay 框架的第一条设计公理。
落到代码里,规则侧的典型内容:胜利与失败条件、重生延迟几秒、一局最多几人、比赛从等待到进行到结束怎么推进。事实侧的典型内容:当前比分、剩余时间、比赛进行到什么状态、场上都有哪些玩家。
GameMode 有一组成员变量专门登记这七个类:DefaultPawnClass、PlayerControllerClass、PlayerStateClass、GameStateClass、HUDClass、SpectatorClass。你在项目设置里选好的那套类,最终都汇总到 GameMode 名下,由它在一局开始时按图施工。
classDiagram
class AGameModeBase {
+DefaultPawnClass
+PlayerControllerClass
+PlayerStateClass
+GameStateClass
+HUDClass
+RestartPlayer()
}
class AGameStateBase {
+PlayerArray
+GetMatchState()
}
class APlayerController {
+Possess()
+PlayerState
}
class APawn {
+PossessedBy()
}
class ACharacter
class APlayerState {
+GetPlayerName()
+GetScore()
}
class AHUD
class UUserWidget
AGameModeBase ..> AGameStateBase : 生成并持有
AGameStateBase o-- APlayerState : PlayerArray 汇总全员
APlayerController ..> APawn : Possess 附身
APlayerController --> APlayerState : 一对一持有
APlayerController --|> AController : 继承
APawn --|> AActor
ACharacter --|> APawn : 加移动组件
APlayerState --|> AInfo
AHUD ..> UUserWidget : 创建并挂载注意两条继承线:Pawn 和 Character 是 AActor 的后代,能放进场景、有位置有碰撞;PlayerController、PlayerState、GameMode、GameState 走 AInfo 或 AController 路线,是逻辑对象,不该往场景里拖。HUD 是 Actor 但只活在本地;UUserWidget 干脆是 UObject,连 Actor 都不是。
补课:本节补"策划视角的游戏规则"基础,程序出身但没带过策划案的重点看。
一份规则清单通常长这样:胜利条件(率先拿到 30 分)、失败条件(倒计时归零时落后)、重生规则(死后 3 秒在己方基地复活,无敌 1 秒)、计分规则(击杀 +1,助攻 +0.5,占点每秒 +0.1)、时间限制(一局 10 分钟)。
拿这张清单对照类:胜利/失败/重生是判定,进 GameMode;比分、倒计时是人人可见的数值,进 GameState;个人击杀数是"这个人的成绩",进 PlayerState。清单上每一条都能且只能落到一个类里——落不进去,说明策划案写得含糊,回去改案,别在代码里将就。
GameMode 还有一条硬核纪律,单机开发时容易被忽略:联机时 GameMode 只存在于服务器。客户端调 GetGameMode() 拿到的是 nullptr。客户端想知道"比赛还剩多久",只能读 GameState——GameState 是服务器生成后复制(Replication)到每个客户端的副本。为什么这样设计?规则是权威,权威不能广播副本,否则就会出现"100 个客户端 100 套规则"的灾难。这条纪律是卷三全部网络章节的地基,现在先把单机当"一个人开的服务器"来理解:GameMode 在本地,规则你说了算。想提前亲眼验证"客户端没有 GameMode",不用等卷三:编辑器 Play 按钮下拉里把 Number of Players 设为 2、勾选 Run Dedicated Server,PIE 会拉起三个进程。在客户端窗口打印 GetGameMode(),nullptr 会准时出现。
规则与事实之间也有灰色地带。比如"房间人数上限 100"是规则参数,"当前房间里 73 人"是事实。处理原则:参数放 GameMode(或更进一步,放进数据资产,见第 4 章),派生出来的、人人要看的当前值放 GameState。UI 只读 GameState,逻辑判定只问 GameMode,两边井水不犯河水。
AGameMode(注意不是轻量的 AGameModeBase)内置了一个比赛状态机 MatchState,一局游戏的生命周期就是它在推进:
stateDiagram-v2
[*] --> WaitingToStart : 服务器开房
WaitingToStart --> InProgress : 人数凑齐/倒计时结束
InProgress --> InProgress : 击杀/占点/计分
InProgress --> WaitingPostMatch : 胜负判定(GameMode)
InProgress --> Aborted : 房主离开/异常中断
WaitingPostMatch --> LeavingMap : 结算界面倒计时结束
Aborted --> LeavingMap
LeavingMap --> [*] : 切换关卡或回大厅状态由 GameMode 调用 SetMatchState 推进,GameState 负责把这个状态复制给所有客户端,客户端的 UI 监听状态变化切界面。规则做决定,事实做广播——还是那两句话。
这里有个选型问题:AGameModeBase 和 AGameMode 继承哪个?前者是轻量版,管玩家进出、生成身体、登记类,但没有 MatchState 状态机;后者在前者之上加了整套比赛流程。判断标准很朴素:你的游戏有"比赛"这个概念吗?对战、竞速、占点,直接上 AGameMode,状态机白送;单机叙事、探索、解谜,AGameModeBase 足够——本来就没有 WaitingToStart,硬套一个状态机反而是噪音。「回响峡谷」用的就是 GameModeBase,规则只有重生和结局判定两条。
规则侧写代码是什么手感?看一个重生规则的实现。它解决的问题:角色死亡后 Pawn 销毁,但"几秒后重生"这条规则必须活在 Pawn 之外。
// EchoGameMode.cpp 节选:重生规则,与身体无关
void AEchoGameMode::OnPlayerDied(AEchoPlayerController* Victim)
{
FTimerDelegate D;
D.BindUObject(this, &AEchoGameMode::RespawnPlayer, Victim);
FTimerHandle H;
GetWorldTimerManager().SetTimer(H, D, RespawnDelay, false); // 规则参数: 2.5s
}
void AEchoGameMode::RespawnPlayer(AController* Who)
{
RestartPlayer(Who); // 引擎内部: 选出生点 → 按 DefaultPawnClass 生成新身体 → Possess
}这段代码放在 GameMode 里,Pawn 死了十次计时器也纹丝不动。放在 Pawn 的 Tick 里,就是本章开头那个凌晨两点的 bug。
事实侧为什么值得单独一个类?算一笔网络账就懂了。假设错误地把"重生倒计时"当成每个玩家 Pawn 的属性高频同步,对比正确做法(GameState 上一个字段低频推送),服务器上行带宽的估算式:
代入数量级:100 个玩家的对局,错误做法下 100 个 Pawn 各自携带 4 字节倒计时,Actor 默认 NetUpdateFrequency 为 100Hz,100 条连接全部接收,;正确做法是 GameState 上一个字段每秒推一次,。差四个数量级。这还没算每个对象独立的包头开销。公共事实集中到 GameState,不只是架构洁癖,是带宽纪律。
这个公式在卷三会反复出场:第 21 章算单连接带宽预算时, 会换成整个区域的相关 Actor 数, 会换成完整状态包。现在先建立直觉——同步开销是乘法,任何一个因子拍脑袋放大,最后都是服务器带宽在买单。
PlayerController 是手,Pawn 是身体
第二句记忆点来了:PlayerController 是玩家的"手",Pawn 是玩家的"身体"。
手代表玩家的意图:按键、转镜头、点开菜单、下达移动指令。身体代表玩家在世界里的存在:有位置、有碰撞、会受伤、能被看见。两者通过 Possess(附身)绑定:Controller 调用 Possess(SomePawn),这具身体就听这只手的。
为什么要拆开?因为游戏里到处都是"换身体"的时刻,而手不能跟着换:
- 死亡重生:旧 Pawn 销毁,新 Pawn 生成,Controller 原地不动,重新附身。
- 上载具:Controller 从角色身上挪到车上,角色留在原地。
- 观战模式:Controller 附身到一个飞行摄像机 Pawn 上。
- 附身/夺舍类玩法:同一个 Controller 轮流控制不同的 NPC。
如果这些逻辑里有一条写着"销毁旧 Controller 再建新的",输入绑定、镜头参数、本地设置全部要重建,代码会丑到没法看。手与身体分离,换来的是"换身体成本约等于零"。
玩家进入一局游戏时,这套对象的创建顺序是新手最容易搞混的地方。单机 PIE 按下 Play 的那一刻,发生了这些事:
sequenceDiagram
autonumber
participant W as World 世界
participant GM as GameMode
participant GS as GameState
participant PC as PlayerController
participant PS as PlayerState
participant P as Pawn
W->>GM: 创建(联机时仅服务器)
GM->>GS: 按 GameStateClass 生成
W->>GM: 玩家登录 PostLogin
GM->>PC: SpawnPlayerController
PC->>PS: InitPlayerState 按 PlayerStateClass
GS-->>PS: 登记进 PlayerArray
GM->>GM: HandleStartingNewPlayer
GM->>P: RestartPlayer → 生成新身体
PC->>P: Possess 附身
GM-->>W: 一局正式开始三个细节值得记住。其一,PlayerState 是 PlayerController 初始化时顺手创建的,不需要手动 new。其二,PlayerState 创建后会自动登记进 GameState 的 PlayerArray,计分板遍历的就是这个数组。其三,RestartPlayer 不只开局调用,每次重生都走它——上一节那段重生代码的最后一行就是它。
把这条时序竖过来看,就是七件套的权威层级链:World 造 GameMode,GameMode 造 GameState 和 PlayerController,Controller 造 PlayerState,最后才轮到 Pawn 登场。上层的对象天然持有下层的引用,反过来一律靠"问上层要"。理解了这条链,很多"我该去哪拿这个对象"的问题自动有答案:Pawn 想要比分,经过 Controller 问 GameState;Widget 想要血量,经过 OwningPlayer 问 Pawn。任何从下往上硬存的反向引用,都是将来解耦时的债。
镜头归谁管,答案也在这句话里:PlayerCameraManager 挂在 PlayerController 上,而不是 Pawn 上。运镜参数、镜头混合、过场切镜,全部是"手"的职责。身体是架子,手才决定看哪里、怎么看——前面 Character 配置里弹簧臂响应 Controller 旋转,就是这条分工的最小体现。
输入绑定放手还是放身体,社区里吵了很多年。UE 给的答案是两边都能绑(Controller 和 Pawn 都有输入组件入口),项目里的健康惯例是:身体动作绑在 Pawn/Character 上(移动、跳跃、开火,换身体就换一套动作),全局意图绑在 Controller 上(开菜单、切镜头模式、地图键)。第 5 章讲 Enhanced Input 时会看到,MappingContext 统一挂在 Controller 侧的子系统上,具体 Action 的回调落在哪边,就按这个惯例分。
换身体的完整链路值得走一遍。以载具玩法为例,上车的本质是两次附身操作:Controller 对角色 UnPossess,再对车 Possess。角色 Pawn 留在原地继续 Tick(可以被怪打、可以等你回来),车速表、氮气量这些"新身体的数据"立刻归你调用。下车就是反向操作。这套链路上任何一环数据放错位置都会露馅:车速表存角色身上,开车时你读不到;角色血量存车上,下车时血条直接消失。
附身切换有两个钩子:OnPossess 和 OnUnPossess(Pawn 侧的虚函数)。身体动作类输入的绑定与解绑、身体专属 UI(比如载具仪表盘)的显示与隐藏,都该挂在这两个钩子里,而不是散落在交互逻辑中。钩子里干活的另一个好处是重生自动受益——新身体生成时会走一遍同样的附身流程。
顺带一提观战:GameMode 的花名册里还有第八个影子成员 SpectatorClass,默认是一个能自由飞行的 SpectatorPawn。它的工作原理没有任何魔法——Controller 从战死的身体上退下来,附身到这具飞行摄像机身上。观战系统只是"换身体"的又一次应用。
联机时还有个反直觉的事实:服务器上为每个连接的玩家都存着一个 PlayerController 实例——玩家的输入 RPC 就是发到服务器端这个 Controller 上的。但客户端只有自己的 Controller,拿不到别人的。输入是私事,这就是原因。这个镜像还有实战用途:服务器要给某个玩家单独发消息(只属于你的任务更新、私聊),投递地址就是他那台 Controller——它是服务器上与该客户端一一对应的专属通道。
PlayerState:换身体带得走的身份证
一个灵魂拷问:玩家死了,Pawn 进坟,他的击杀数去哪了?
如果击杀数存在 Pawn 上,答案是没有如果——跟着进坟。这就是 PlayerState 存在的理由:它是玩家的身份证,记录一切"换身体也带得走"的数据。死亡重生销毁的是 Pawn,PlayerController 和 PlayerState 都原地不动,新身体落地后从 PlayerState 里把数据读出来,什么都不丢。
PlayerState 的典型内容:
- 成绩数据:击杀数、死亡数、助攻、伤害总量
- 身份数据:昵称、段位、头像 ID、公会标签
- 阵营数据:队伍编号、职业、出生点分组
- 状态标记:是否观战、是否挂机、是否已准备
判断口诀:玩家死了还在的,进 PlayerState;跟着身体死的,进 Pawn。
PlayerState 在联机里还有第二个身份:它是"全员可见的玩家名片"。上一节说过,所有玩家的 PlayerState 都登记在 GameState 的 PlayerArray 里,而这个数组会复制到每个客户端。也就是说,你的客户端上能拿到场上每一个人的 PlayerState 副本——计分板能列出全场的名字和击杀数,靠的就是这个设计。不需要额外的查询协议,数据本来就在。
全员可见这个特性也划出了 PlayerState 的禁区:别的玩家不需要知道的数据,别往 PlayerState 里放。背包里有多少金币、任务做到哪一步——这些数据复制给全场 100 个客户端,既浪费带宽(代入第一节的公式算算),又等于把隐私数据广播出去给改包工具看。私密数据该走"只发给 Owner"的通道,这是第 21 章条件复制的议题。PlayerState 的命名纪律可以浓缩成一句:只放计分板配看到的东西。
对比一下两种放法的后果:
| 数据 | 错误放法 | 正确放法 | 放错的代价 |
|---|---|---|---|
| 击杀数 | Pawn 成员变量 | PlayerState | 重生清零,计分板闪烁 |
| 队伍编号 | Pawn 成员变量 | PlayerState | 重生后敌我不分,友伤事故 |
| 当前血量 | PlayerState | Pawn/Character | 换身体时新旧血量打架,治疗逻辑要写两份 |
| 手上这把武器 | PlayerController | Pawn | 切观战时武器跟着手走了,身体空手站着 |
| 昵称 | Pawn 成员变量 | PlayerState | 头顶名字在重生瞬间消失一帧,UI 疯狂重建 |
血量为什么放 Pawn 而不是 PlayerState?因为血是"身体"的属性:这具身体死了,新身体是满血还是残血,由规则(GameMode)决定,而绝大多数游戏的答案都是满血。把血放 PlayerState,等于强迫规则层每次重生手动重置一遍,多一道工序,多一个出 bug 的接口。
PlayerState 的生命周期节点也记一下:它在玩家连接建立时创建,早于任何 Pawn;玩家掉线时不立即销毁,留着等重连(具体保留策略由游戏定);玩家彻底离场才跟着 Controller 一起回收。也就是说,PlayerState 是七件套里除了 GameMode/GameState 之外活得最久的对象——这也是"成绩数据归它"的另一个理由。
最后说一个进阶场景:无缝旅行(Seamless Travel)。MMO 里跨地图时服务器会重建 PlayerController,PlayerState 跟着重建,老数据靠 APlayerState::CopyProperties 搬过去。卷三做到跨 Zone 迁移(见第 25 章)会再次用到它。单机项目现在不用管,知道这个机制存在就行。
Character 是人形全家桶,不是必选项
很多教程上来就建 Character,给人造成"Pawn 已经过时"的错觉。事实是反的:只有当实体是"用两条腿在世界里走的人形生物"时,Character 才值得上场。弹珠台里的球、自由飞行的摄像机吊臂、漂浮的魔法飞弹,用裸 Pawn 甚至普通 Actor 更合适,拖一个 Character 等于背着一套用不上的运动系统。
裸 Pawn 的正当用途比想象中多。引擎自带两个现成的:DefaultPawn 是一个带悬浮移动组件的球体,常用于编辑器内预览和原型阶段;SpectatorPawn 是观战摄像机。自己做的玩法实体里,无人机视角、附身炮塔、过场演出用的轨道相机,都是裸 Pawn 加一两个组件就能搞定的活。判断标准还是那句:它需要"走路"吗?不需要,就别背 CMC 这口锅。
Character 在 Pawn 之上打包了四样东西:
UCapsuleComponent作为根组件:碰撞外形,人形直立的最简近似USkeletalMeshComponent:骨骼网格,动画的载体UCharacterMovementComponent(CMC):行走、跳跃、游泳、飞行、坠落全套运动求解- 一套与动画系统的约定:加速度、朝向、速度自动可供动画蓝图读取
真正值钱的是 CMC。别当它是"帮你省几行移动代码"的工具——它本质上是一套带网络预测的运动协议:客户端本地先跑模拟,服务器权威校正,偏差大了做平滑回滚。你在单机里调的每个参数,联机时都在这套预测框架里继续生效——这是 UE 角色移动在联机游戏里手感始终在线的原因。预测与校正的细节放到第 21 章,现在只需要记住:用 CMC,就是为联机买了保险。
「回响峡谷」主角的身体配置长这样,它解决的问题:第三人称角色"身体朝移动方向转、镜头独立旋转"的标准手感。
// EchoCharacter.cpp 节选:第三人称身体的最小可用配置
AEchoCharacter::AEchoCharacter()
{
// 身体不听手柄的 Yaw:朝移动方向转身,转身速率 540°/s
bUseControllerRotationYaw = false;
auto* Move = GetCharacterMovement();
Move->bOrientRotationToMovement = true;
Move->RotationRate = FRotator(0.f, 540.f, 0.f);
// 手感三件套:走速 / 起跳 / 空中控制
Move->MaxWalkSpeed = 420.f;
Move->JumpZVelocity = 520.f;
Move->AirControl = 0.35f;
Move->BrakingDecelerationWalking = 1200.f;
// 弹簧臂 + 相机:身体是架子,镜头听 Controller 的
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(RootComponent);
SpringArm->TargetArmLength = 320.f;
SpringArm->bUsePawnControlRotation = true;
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm);
}三个要点:bUseControllerRotationYaw = false 加 bOrientRotationToMovement = true 是"身体朝移动方向转"的固定搭配,两者只开一个会出现身体与镜头抢方向盘的怪相;AirControl 取值 0 到 1,0.35 是动作游戏的常见起点,平台跳跃类往 0.6 以上调;弹簧臂的 bUsePawnControlRotation 让镜头响应 Controller 的视角输入——手管镜头,身体管脚,职责在这条配置里体现得清清楚楚。
CMC 的全部参数在细节面板的 Character Movement 分组下,三百多个,别试图全读。先认三组就够干活:Walking(走速、刹车减速度)、Jumping/Falling(起跳速度、重力缩放、空气控制)、Rotation(转身速率与朝向策略)。手感调试第 9 章会系统过一遍,现在先把 MaxWalkSpeed 和 JumpZVelocity 摸熟。
HUD 类只剩一件事:给 UMG 当挂载点
AHUD 是个有历史的类。UE3 时代它靠 Canvas 一帧帧画文字和贴图,是真干活的界面层。到了 UE 5.8,正经界面全部是 UMG(UUserWidget)的天下:保留模式、数据驱动、有动画系统、支持 MVVM。Canvas 那套即时绘制只剩调试用途。
HUD 类在项目里的现代职责收缩成一件:当 UMG 界面的挂载点与生命周期管理者。开局创建主界面 Widget、死亡时切到结算界面、按区域切换界面皮肤——这些"什么时候该有什么界面"的判断放 HUD 类里,界面内部长什么样交给 UMG。
界面与世界的锚点也值得说清。创建 Widget 时 CreateWidget 要传一个 OwningPlayer,惯例是传本地的 PlayerController——Widget 由此拿到"手",顺藤摸瓜:手的 GetPawn() 是身体,手持有的 PlayerState 是身份证,世界里的 GameState 是公共事实。血条、小地图、任务追踪,所有界面元素的数据入口都从这一个锚点出发。传错 Owner(比如传了 nullptr 或别的玩家的 Controller)是 UMG 新手期第二常见的 bug,仅次于把数据存进 Widget。
比挂载更重要的是一条铁律:UI 不存数据,只拉数据。血条的血量读 Pawn,计分板的击杀数读 PlayerState,倒计时读 GameState。反例随处可见:把当前血量缓存在 Widget 的成员变量里,由受到伤害的逻辑"顺便"更新 UI——三天后就会出现逻辑层血量 80、UI 显示 100 的灵异现象,因为某条扣血路径忘了"顺便"。单一数据源这句话在 UI 层的翻译就是:Widget 是 State 的投影,不是 State 的副本。
AHUD 也没有彻底退役。它的 Canvas 即时绘制在两个场景仍是合理选择:一是调试信息(帧率、命中盒、AI 感知范围这类不进正式 UI 的东西),二是命中标记(HitMarker)这类对延迟敏感到一帧都不想等的即时反馈。原则不变:正式界面归 UMG,临时绘制归 Canvas。记住一点:它是门房,业主另有其人。
UMG 的数据绑定、CommonUI 的输入路由、MVVM 在 UE 的落地,是第 19 章的正餐。
决策树总纲,以及「回响峡谷」怎么落
七个类讲完,回到日常最高频的那个问题:策划提了一个新需求,这段逻辑写进哪个类?下面这张决策树是全章的灵魂,覆盖了新人最容易放错位置的 11 个典型场景。把它贴在工位上,比收藏十篇教程有用。
flowchart TD
S["拿到新需求:它是规则、事实,还是表现?"] --> Q1{"是规则判定吗
胜负 / 重生 / 人数 / 流程推进"}
Q1 -- "是" --> GM["GameMode
联机时只在服务器"]
Q1 -- "否" --> Q2{"是一局的公共事实吗
所有玩家都要看到"}
Q2 -- "是" --> GS["GameState
自动复制到所有客户端"]
Q2 -- "否" --> Q3{"玩家死亡重生后
这份数据还要吗"}
Q3 -- "要,跟人走" --> PS["PlayerState"]
Q3 -- "跟身体一起死" --> PW["Pawn / Character"]
S -.-> Q4{"只是排版与动效"}
Q4 -- "是" --> UI["UMG 界面
数据向 State 拉,自己不存"]
S -.-> Q5{"是玩家的意图
按键 / 镜头 / 菜单"}
Q5 -- "是" --> PC["PlayerController"]
GM --- E1["胜利判定"]
GM --- E2["重生倒计时 2.5 秒"]
GM --- E3["房间人数上限 100"]
GS --- E4["团队比分"]
GS --- E5["比赛剩余时间"]
PS --- E6["个人击杀数"]
PS --- E7["队伍 / 昵称 / 段位"]
PW --- E8["当前血量"]
PW --- E9["手上这把武器"]
PC --- E10["输入映射"]
UI --- E11["血条与计分板布局"]决策树的使用手感可以压缩成一段伪代码,30 秒过完一遍:
# 伪代码:新需求的归属判断,按顺序问,命中即停
def where_does_it_go(feature):
if feature.is_rule(): # 胜负、重生、人数、比赛流程
return GameMode # 联机时只活在服务器
if feature.is_public_fact(): # 比分、剩余时间、比赛状态
return GameState # 复制给所有人
if feature.is_player_intent(): # 按键、镜头、菜单开关
return PlayerController # 手
if feature.survives_respawn(): # 击杀、队伍、昵称、段位
return PlayerState # 身份证
if feature.belongs_to_body(): # 血量、武器、位置
return Pawn_or_Character # 身体
if feature.is_pure_display(): # 布局、动效、皮肤
return UMG # 只投影,不存储
raise DesignSmell("需求描述含糊,先回去改策划案")决策树看着直白,真到项目里照样有人踩坑。三个我从代码评审里捞出来的真实错题:
错题一:武器挂手上。 把"当前武器"写成 PlayerController 的成员,理由是"开枪指令从 Controller 进来,顺手"。结果一切到观战,观战视角的 HUD 上还挂着死前那把枪的图标——武器跟着手跑了。武器是身体的一部分,进 Pawn。
错题二:倒计时读 GameMode。 客户端 UI 直接 GetGameMode()->RemainingTime。单机测试一切正常,联机一上来客户端就崩——前面说过,客户端的 GetGameMode() 是 nullptr。剩余时间放 GameState 复制,UI 从那边读。
错题三:团队比分塞 PlayerState。 红队总分写进红队队长的 PlayerState,理由是"反正都在 PlayerArray 里"。客户端想显示比分就得先找到队长是谁,队长掉线比分直接消失。团队级数据进 GameState,或者每个玩家 PlayerState 里放冗余的"所属队伍总分"字段——但权威那份永远在 GameState。
到目前为止的讨论都是单机视角。这套划分真正的威力在联机时才显现:七个类在两个进程里的分布是不对称的,而分布规则正好就是本章的两句记忆点——规则不出服务器,事实全员广播,手是私人物品,身体人人都有影子。
图上三条不对称性值得再念一遍。GameMode 从不出服务器大门,客户端连它的指针都拿不到——所以"问规则要结果"必须绕道 GameState。PlayerController 每台机器上只有自己那一个,别人的手不归你管——所以计分板不能遍历 Controller,只能遍历 GameState 的 PlayerArray。Pawn 人人都有,但客户端手里全是服务器权威实体的影子——所以影子位置永远滞后,移动手感要靠预测补,这是 CMC 和第 21 章的共同话题。单机开发时把规矩守好,将来搬上联机就不用重写——这就是单机项目也要认真对待七件套的终极理由。
规矩落到「回响峡谷」里,是这张具体的落位表。demo 里每个类的实例叫什么、管什么,一章说清:
| 类 | 实例 | 管什么 | 里面有什么 |
|---|---|---|---|
| GameMode | BP_EchoGameMode | 规则 | 死亡后 2.5 秒在最近检查点重生;集齐 12 枚回声碎片解锁结局演出 |
| GameState | AEchoGameState | 事实 | 已收集碎片计数、当前章节(峡谷入口/回声矿洞/断桥) |
| PlayerController | AEchoPlayerController | 手 | 镜头输入、Enhanced Input 映射挂载、暂停菜单与地图开关 |
| PlayerState | AEchoPlayerState | 身份证 | 死亡次数统计(结局评价用);单机里刻意保持单薄 |
| Pawn/Character | AEchoCharacter「艾拉」 | 身体 | 血量、体力、回声刀装备槽、CMC 手感参数 |
| HUD | AEchoHUD | 面子 | 创建并持有主界面 Widget;死亡时切换结算界面 |
| 默认值登记 | GameMode 成员 | 花名册 | DefaultPawnClass=AEchoCharacter 等六条登记 |
这张表同时是团队的沟通合同。策划问"碎片集齐会发生什么",去 GameMode 找;问"界面上碎片计数哪来的",答 GameState 投影到 UMG;问"死亡次数在结局怎么算的",答 PlayerState。每句话都有唯一答案,没有"应该在某某蓝图里好像也有一份"。
注意 PlayerState 那一行的"刻意保持单薄":单机游戏没有换身体带数据的需求,也没有计分板,PlayerState 几乎无用武之地。留着它、按规矩只放该放的数据,是零成本的纪律——哪天「回响峡谷」要加双人协作,死亡次数、收集贡献直接就能上计分板,一行架构都不用改。
表里有对容易混淆的邻居值得点一句:碎片计数放 GameState,已解锁章节放 GameInstance。区别在存活范围——计数是"这一局探索的事实",角色死亡、检查点重生它都在,但退出到主菜单这局就结束了;解锁章节是"这个存档的持久进度",必须活过关卡切换甚至进程重启。一局之内归 GameState,局与局之间归 GameInstance,落位时先问这条寿命问题。
最后补一个七件套之外的常客:UGameInstance。它管的是"局与局之间"——图形设置、存档槽位、跨关卡传递的进度、累计成就。生命周期从进程启动到退出,关卡切换、GameMode 销毁重建,它都活着。新手常犯的对称错误就是把全局数据塞进 GameMode:一切换关卡,GameMode 销毁,设置全丢。记忆方式照旧一句话:GameMode 管一局之内,GameInstance 管局与局之间。「回响峡谷」里玩家的画质设置和已解锁章节,就放在 UEchoGameInstance 里。
小结
GameMode 管规则,GameState 管事实;规则做判定,事实做广播,两者混淆是一切诡异的源头。PlayerController 是手,Pawn 是身体;换身体的玩法(重生/载具/观战)全靠这层分离兜底。跟人走的数据进 PlayerState,跟身体死的进 Pawn;拿不准就问"玩家死了它还在吗"。HUD 类只剩门房职责,UI 永远向 State 拉数据,不自己存。联机时七件套分布不对称——GameMode 只在服务器,客户端只有影子——单机守规矩就是在给卷三交首付。
上手任务
- 建空白 C++ 工程,创建
GameMode/GameState/PlayerController/PlayerState/Character/HUD六个自定义类并在 GameMode 里登记。验收:PIE 后 World Outliner 里能逐个找到它们,输出日志打印的BeginPlay顺序与本章时序图一致。 - 实现完整死亡重生:血量归零销毁 Pawn,2.5 秒后在出生点重生,击杀数不丢。验收:连续死亡 5 次,界面上的击杀计数从 0 单调递增到 5,中途不归零。
- 故意复现开篇事故:把重生倒计时写进 Pawn 的 Tick,观察死亡后无法重生;再改回 GameMode 的 Timer 实现。验收:能用一句话向同事讲清两种行为的根因(对象生命周期不同)。
- 拿决策树过一遍手头项目的五个真实需求(血量/击杀/倒计时/输入/UI 各一)。验收:每个需求 30 秒内给出归属类,且能说出否决其他类的理由。
下一章
类的地盘划完了,下一个问题接踵而至:同一块地盘,C++ 和蓝图谁来耕?第 4 章给出团队协作的硬规则——哪些东西禁止进蓝图,以及为什么蓝图真正的成本不在运行慢。
延伸阅读
- 官方文档:Unreal Engine → Programming and Scripting → Gameplay Framework(Game Mode、Game State、Player Controller、Pawn 各词条)
- 官方文档:Unreal Engine → UMG UI Designer(HUD 与 UMG 的分工语境)
- 源码:
Engine/Source/Runtime/Engine/Classes/GameFramework/下的 GameModeBase.h、GameStateBase.h、PlayerState.h,注释即规格 - 源码:
Engine/Source/Runtime/Engine/Private/GameMode.cpp中RestartPlayer的实现,对照本章时序图读 - GDC 2016:Unreal Engine 的 Gameplay Framework 演进相关分享(Epic 官方场次),看这套七件套当年是如何从 UE1 一路长成今天这样的