1899 字
9 分钟
Gameplay Message——用事件把 Gameplay 系统解耦

一、系统为什么会互相“知道太多”#

一个回合结束时,可能需要:

  • UI 显示结算面板
  • 音频播放胜利音效
  • 统计系统记录本回合数据
  • 成就系统检查是否完成条件
  • 观战系统保存回放标记
  • 任务系统推进任务进度

最直接的写法是让 QuestSubsystem 逐个调用它们:

QuestSubsystem->ShowResultWidget(Result);
QuestSubsystem->PlayVictorySound(Result);
QuestSubsystem->RecordStatistics(Result);
QuestSubsystem->CheckAchievements(Result);
QuestSubsystem->SaveReplayMarker(Result);

这会让回合系统知道所有下游模块的类名、生命周期和函数签名。加一个“录像系统”,就要修改回合系统;删一个音效模块,也可能影响编译。

消息通信的思路是:

发布者:QuestSubsystem
→ 发布“Message.Mission.Finished”消息
订阅者:UI / Audio / Stats / Achievement / Replay
→ 各自决定要不要处理

发布者只知道发生了什么,不知道谁会处理


二、直接调用和消息通信怎么选#

消息不是所有场景的答案。可以用这张表判断:

场景更适合
必须拿到返回值才能继续直接调用
明确的一对一依赖接口 / 直接调用
一个事件有多个独立订阅者消息
发送者不应该依赖接收者消息
需要跨网络到达另一台机器RPC / Replicated 状态
高频逐帧数据专门的数据通道
// 一对一、需要结果:直接调用
const bool bCanUseAbility = RuleService->CanUseAbility(PlayerId, AbilityTag);
// 一对多、无需知道接收者:发消息
MessageBus->BroadcastMessage(
GameplayChannels::MissionFinished,
Result);

消息的价值是减少依赖,不是隐藏依赖。 如果系统有十几个订阅者,仍然需要能追踪“谁订阅了这个频道、谁会修改什么”。


三、GameplayTag 是很好的消息频道名#

前面讲过 GameplayTag 是系统之间的“类型安全字符串”。消息系统可以直接使用层级 Tag 作为频道:

Message.Mission.Started
Message.Mission.Finished
Message.Ability.Activated
Message.Ability.Rejected
Message.Item.PickedUp

调用者不需要依赖 UMissionResultWidget,它只发布:

FGameplayTag Channel = FGameplayTag::RequestGameplayTag(
TEXT("Message.Mission.Finished"));
FObjectMessage Payload;
Payload.Object = ResultObject;
UGameplayMessageSubsystem::Get(this).BroadcastMessage(
Channel,
Payload);

在使用 Lyra 风格的 GameplayMessageRuntime 时,可以用 UGameplayMessageSubsystem 和强类型 Payload;如果项目没有启用该插件,也可以实现一个自己的 UGameInstanceSubsystem 消息总线,保留相同的思路。

1 频道命名要稳定#

频道名称会被代码、蓝图和调试工具共同引用。建议:

  • Message. 作为根节点,和状态 Tag 区分
  • 名词描述事件,不要用某个实现类的名字
  • 频道表达业务语义,不表达 UI 表现
  • 需要层级监听时,提前定义父子关系
Message.Ability.Activated // 业务事件
Message.Ability.PlaySound // 表现细节,不建议作为公共频道

“技能释放成功”可以同时驱动音效、UI 和统计;“播放某个音效”只应该是音频系统内部的事情。


四、Payload:消息要带多少信息#

消息必须有足够信息让订阅者工作,但不能把整个系统的内部对象都塞进去:

USTRUCT(BlueprintType)
struct FAbilityActivatedMessage {
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
TObjectPtr<const APlayerState> Player = nullptr;
UPROPERTY(BlueprintReadOnly)
FGameplayTag AbilityTag;
UPROPERTY(BlueprintReadOnly)
int32 ActivationSequence = 0;
};

推荐传递:

  • 稳定的 ID
  • 事件发生时的快照值
  • 必要的上下文(回合号、玩家 ID)
  • 不可变的结果数据

尽量不要传递:

  • 订阅者可以自己修改的可变全局对象
  • 只因为某个 UI 恰好需要才加入的字段
  • 让接收者必须知道内部实现的“万能上下文”

如果所有消息都只有一个 void* Context,那不是解耦,而是把类型错误推迟到了运行时。


五、监听生命周期比广播更容易出问题#

FGameplayMessageListenerHandle ListenerHandle;
void UMissionHUDWidget::NativeConstruct() {
Super::NativeConstruct();
ListenerHandle = UGameplayMessageSubsystem::Get(this)
.RegisterListener<FAbilityActivatedMessage>(
GameplayChannels::AbilityActivated,
this,
&UMissionHUDWidget::OnAbilityActivated);
}
void UMissionHUDWidget::NativeDestruct() {
UGameplayMessageSubsystem::Get(this)
.UnregisterListener(ListenerHandle);
Super::NativeDestruct();
}

必须处理这些情况:

  1. Widget 反复打开时不会重复注册
  2. 监听者销毁后,消息总线不会留下悬空对象
  3. Subsystem 重建时,旧 Handle 不会被错误复用
  4. 测试结束时,所有监听都能清理

RAII、Delegate Handle 或 UObject 生命周期绑定都可以实现自动解绑。谁注册,谁负责取消注册,是最容易记住的规则。


六、消息不是网络复制#

这是消息系统最容易被误用的地方:

服务器 BroadcastMessage(AbilityActivated)
→ 只在服务器进程内通知订阅者
✕ 不会自动到达客户端

如果客户端也需要知道技能释放成功,需要先走网络边界:

客户端请求 ServerUseAbility
→ 服务器验证并修改 Replicated MissionState
→ 客户端收到属性变化 / Client RPC
→ 客户端本地 BroadcastMessage
→ UI、音频、统计各自响应

服务器上的消息适合连接服务器内部的规则、日志和结算系统;客户端上的消息适合连接客户端表现。不要把本地消息总线当作跨机器传输协议。

1 可恢复状态和瞬时事件#

任务进度 = 3 / 5
→ Replicated 属性,晚加入客户端也能得到
“技能图标闪一下”
→ 本地消息,错过了也不影响事实

消息可以由状态变化触发,但不能取代可恢复的权威状态。


七、消息处理顺序和线程边界#

多数 Gameplay 消息在 Game Thread 上同步分发:

BroadcastMessage()
→ 依次调用当前订阅者
→ 订阅者可能继续发布新消息
→ 当前调用栈内完成

这带来两个注意点:

  • 不要在消息回调里做长时间阻塞的文件或网络 I/O
  • 不要依赖“某两个订阅者的注册顺序”表达业务顺序

如果业务真的需要顺序:

MissionFinished
→ 任务系统先生成 FinalResult
→ 再发布 ResultReady
→ UI / Audio / Stats 只消费最终结果

把顺序建模成状态或阶段,比依赖 Delegate 的偶然调用顺序更稳。


八、和 GAS、Subsystem 的组合#

这几篇文章里的组件可以这样分工:

Gameplay Ability
→ 触发技能、应用 GE、改变 Tags
World / GameInstance Subsystem
→ 管理跨对象的业务服务
Gameplay Message
→ 传播“发生了什么”
Replicated State
→ 让其他机器知道“现在是什么”

例如“完成任务目标”:

Ability.Activate
→ 服务器检查权限与消耗
→ QuestSubsystem.CompleteObjective()
→ 修改 ObjectiveState = Completed
→ Broadcast Objective.Completed(服务器本地)
→ Replicated State 到客户端
→ 客户端 Broadcast Objective.Completed
→ UI、音效、相机各自处理

每一层只做自己的事:GAS 管技能规则,Subsystem 管业务服务,复制管网络事实,消息管本地解耦。


九、合作游戏消息频道示例#

Message.Mission.Started
Payload: MissionId、MapId、EndTime
Message.Ability.Activated
Payload: PlayerId、AbilityTag、Sequence
Message.Ability.Rejected
Payload: PlayerId、AbilityTag、RejectReason
Message.Item.PickedUp
Payload: ItemId、PlayerId、WorldLocation
Message.Mission.Finished
Payload: TeamId、RewardId、CompletionId

其中 AbilityRejected 很适合本地 UI 显示提示,但真正的拒绝原因仍然必须来自服务器;客户端不能自己推断“服务器应该允许”。


十、总结#

概念一句话
直接调用有明确一对一依赖、需要返回值时使用
消息一对多、发布者不需要知道接收者时使用
Channel用稳定的 GameplayTag 表达业务事件名称
Payload带最小但完整的不可变事件快照
Listener Handle管理订阅生命周期,避免重复回调和悬空引用
网络边界本地消息不会自动跨机器,仍需 RPC 或复制

消息系统不是为了让代码“看起来没有依赖”,而是把依赖从类名依赖变成了业务事件依赖。 事件命名、Payload 类型和生命周期都认真设计,系统才是真的松耦合。

Gameplay Message——用事件把 Gameplay 系统解耦
https://www.m4doka.xyz/posts/ue/ue-13-gameplay-messaging/
作者
m4doka
发布于
2026-08-26
许可协议
CC BY-NC-SA 4.0