UE5 Lyra Messages 模块深度解析——从一条击杀消息看游戏事件系统的设计哲学

Lyra Messages 模块深度解析------从一条击杀消息看游戏事件系统的设计哲学

写在前面:消息系统到底在解决什么问题?

不知道大家有没有遇到过这种场景:策划说"能不能加个功能,当玩家A击杀玩家B的时候,弹一个击杀播报,同时记个连杀计数,再放个特效,顺便给个成就"------听起来好像就是"加个功能"的事,但如果我们的代码里,击杀逻辑是写在武器伤害系统里的,播报写在UI Widget里,连杀计数写在PlayerState里,成就写在另一个独立模块里......那就意味着要让四五个彼此毫无交集的系统同时响应同一件事。

最笨的办法当然是在每个需要响应的地方都写一份代码,但这样一来,每次新增一个要"响应击杀"的系统,就得跑去修改武器伤害代码。很快项目就会变成一个谁都不敢碰的意大利面。

UE其实早就准备好了方案,而且很优雅:UGameplayMessageSubsystem。Lyra在此基础上,构建了一套精炼的消息基础设施------Messages模块。它不是什么庞大的框架,整个模块总共也就七八个文件,但架起了整个游戏事件通信的骨架。

这篇文章就是来拆这套骨架的。从"为什么要这么设计"讲到"每一行代码在干什么",再讲到"Lyra项目里怎么用的"。


1. 先把地图摊开:模块整体架构

Messages模块位于 Source/LyraGame/Messages/ 目录下,六个文件,各司其职:

复制代码
Messages/
├── GameplayMessageProcessor.h/cpp      --- 消息处理器基类
├── LyraVerbMessage.h                    --- 动词消息结构体
├── LyraVerbMessageHelpers.h/cpp         --- 辅助函数库
├── LyraVerbMessageReplication.h/cpp     --- 网络复制容器
└── LyraNotificationMessage.h/cpp        --- UI通知消息结构体

表面上看是五个独立的类,实际上它们围绕一个核心概念展开:"发起者-动词-目标"的消息模型

1.1 一句话解释每个组件的角色
组件 一句话定位 类比
FLyraVerbMessage 消息本身------"谁对谁做了什么" 一封信
UGameplayMessageProcessor 消息的"收件人"------订阅并处理消息 信箱 + 自动分拣机
ULyraVerbMessageHelpers 消息格式转换器------在VerbMessage和GameplayCue之间互转 翻译官
FLyraVerbMessageReplication 网络搬运工------把服务器消息运到客户端 快递
FLyraNotificationMessage UI专用的消息格式------"在哪个频道显示什么" 公告栏贴纸

这里面最容易被低估的是 FLyraVerbMessage。它简单到只有几个字段,但它的设计恰好体现了整个模块的核心思路------我们来看它的定义:

cpp 复制代码
USTRUCT(BlueprintType)
struct FLyraVerbMessage
{
    FGameplayTag Verb;                     // 动词------"发生了什么事"
    TObjectPtr<UObject> Instigator;       // 发起者------"谁干的"
    TObjectPtr<UObject> Target;           // 目标------"对谁干的"
    FGameplayTagContainer InstigatorTags; // 发起者身上的标签(阵营、状态等)
    FGameplayTagContainer TargetTags;     // 目标身上的标签
    FGameplayTagContainer ContextTags;    // 上下文标签(环境、事件类型等)
    double Magnitude = 1.0;              // 量级(伤害值、治疗量、连杀数等)
};

注意到没有------它没有直接用 AActor*,而是用了 TObjectPtr<UObject>。这是一个重要的设计选择:消息的发起者和目标可以是任何 UObject。PlayerState、PlayerController、Actor,甚至是一个DataAsset,都能塞进去。这给了极大的灵活性------一条消息既能描述"玩家A击杀了玩家B",也能描述"武器X消耗了弹药Y"。

1.2 模块间的数据流

光看组件清单可能还不太直观,我们把数据流动画出来:

复制代码
┌──────────────────────────────────────────────────────────────────────────┐
│                  UGameplayMessageSubsystem (引擎层)                        │
│                消息总线------单例,负责广播和路由                               │
└──────────────────────────┬───────────────────┬───────────────────────────┘
                           │                   │
                      广播消息             注册监听
                           │                   │
              ┌────────────▼──────┐  ┌─────────▼──────────────────┐
              │  FLyraVerbMessage │  │ UGameplayMessageProcessor   │
              │  (消息载体)       │  │ (消息处理器基类)            │
              │                  │  │                           │
              │  · Verb          │  │  · StartListening()        │
              │  · Instigator    │  │  · StopListening()         │
              │  · Target        │  │  · AddListenerHandle()     │
              │  · Magnitude     │  │  · GetServerTime()          │
              │  · Tags          │  │                           │
              └────────┬─────────┘  └───────────────────────────┘
                       │                          ▲
                       │ 转换                      │ 继承
                       ▼                          │
              ┌────────────────────┐  ┌───────────┴──────────────────────┐
              │ ULyraVerbMessage   │  │ UElimChainProcessor /              │
              │ Helpers            │  │ UElimStreakProcessor               │
              │ (辅助函数库)       │  │ (实际项目中的子类)                 │
              │                   │  │                                   │
              │ ↔ VerbMessage     │  │ 监听 Lyra.Elimination.Message      │
              │ ↔ GameplayCue     │  │ 处理后重新广播新的VerbMessage       │
              └────────────────────┘  └───────────────────────────────────┘


┌───────────────────────────────┐      ┌───────────────────────────────────┐
│ FLyraVerbMessageReplication   │      │ FLyraNotificationMessage            │
│ (网络复制容器)                │      │ (UI通知消息)                        │
│                              │      │                                    │
│ FastArraySerializer           │      │ · TargetChannel (显示在哪个频道)    │
│ ┌─────────────────────────┐  │      │ · TargetPlayer  (显示给谁)          │
│ │ Server: AddMessage()    │  │      │ · PayloadMessage (文本内容)         │
│ │         MarkItemDirty() │──┼──→   │ · PayloadTag     (样式/定义标签)    │
│ │                         │  │网络  │ · PayloadObject  (附加数据)         │
│ │ Client: PostReplicated  │  │复制  │                                    │
│ │         Add → Rebroadcast│ │      └───────────────────────────────────┘
│ └─────────────────────────┘  │
└───────────────────────────────┘

这个架构最大的好处是解耦 。发送者只管构造一条 FLyraVerbMessage 然后丢给 GameplayMessageSubsystem::BroadcastMessage(),至于谁在听、什么时候处理、处理完又要干什么------发送者完全不需要知道。


2. 核心设计思想:为什么是"动词消息"这个模型?

2.1 从自然语言到代码结构

不知道大家有没有注意过,人类描述事件,不管多复杂,几乎都能拆成同一个句式:

什么情况下 做了什么 对谁结果有多大

比如:

  • "玩家张三 用狙击枪 击杀了 玩家李四,造成 150 点伤害"
  • "火焰区域 灼烧了 范围内的敌人,每秒造成 30 点伤害"
  • "玩家王五 拾取了 地上的武器"

Epic的工程师显然也发现了这个规律,于是 FLyraVerbMessage 的字段就是对这个句式的直接映射:

句式成分 对应字段 类型
Instigator UObject*
做了什么 Verb FGameplayTag
对谁 Target UObject*
什么情况下 InstigatorTags + TargetTags + ContextTags FGameplayTagContainer
结果有多大 Magnitude double

这不仅仅是一个好看的比喻------它实实在在地统一了整个游戏的事件接口。不管是最底层的伤害计算、中层的连杀判定,还是上层的UI播报,大家拿到的都是同一种消息结构

2.2 动词消息的"级联广播"模式

来看一个Lyra项目里的真实数据流------从"角色死亡"到"连杀播报":

复制代码
角色死亡 (HandleOutOfHealth)
    │
    ├─ 构造 FLyraVerbMessage (Verb = Lyra.Elimination.Message)
    │  Instigator = 击杀者的PlayerState
    │  Target = 被杀者的PlayerState
    │
    ├─ BroadcastMessage → UGameplayMessageSubsystem
    │
    ├─→ UElimStreakProcessor::OnEliminationMessage
    │    └─ 累计连杀数,达到阈值时构造新的 FLyraVerbMessage
    │       (Verb = Lyra.ShooterGame.Streak.DoubleKill / TripleKill / ...)
    │       再次 BroadcastMessage
    │       ├─→ 成就系统
    │       ├─→ 语音播报系统
    │       └─→ UI击杀播报
    │
    ├─→ UElimChainProcessor::OnEliminationMessage
    │    └─ 检测连续击杀时间窗口,构造连杀链消息
    │       BroadcastMessage → UI特效系统
    │
    └─→ (服务器) FLyraVerbMessageReplication::AddMessage
         └─ 网络复制到客户端
            └─ PostReplicatedAdd → RebroadcastMessage
               └─ 客户端的本地监听器也能收到通知

一条消息,触发了四个完全独立的系统响应,而各自之间的代码零耦合。这就是发布-订阅模式最优雅的落地。


3. 逐类详解:每一行代码在干什么

3.1 FLyraVerbMessage------消息载体本身
3.1.1 结构分析
cpp 复制代码
// LyraVerbMessage.h
USTRUCT(BlueprintType)
struct FLyraVerbMessage
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    FGameplayTag Verb;                          // "动词"------用什么标签唯一标识这个消息类型

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    TObjectPtr<UObject> Instigator = nullptr;   // 发起者

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    TObjectPtr<UObject> Target = nullptr;       // 目标

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    FGameplayTagContainer InstigatorTags;       // 发起者标签(如所属阵营、Buff状态)

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    FGameplayTagContainer TargetTags;           // 目标标签

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    FGameplayTagContainer ContextTags;          // 上下文标签(如武器类型、伤害属性)

    UPROPERTY(BlueprintReadWrite, Category=Gameplay)
    double Magnitude = 1.0;                     // 数值量级

    LYRAGAME_API FString ToString() const;       // 调试输出
};

几个值得关注的细节:

Magnitude 默认值是 1.0,不是 0.0。 这个默认值是有深意的------很多消息表达的是"发生了某件事"而非"造成了多少数值"。比如"玩家拾取了武器"这种事件,Magnitude 直接取默认值 1.0 就行,不需要额外设置。这样接收方总是可以安全地读取 Magnitude 而不担心它为零。

TObjectPtr<UObject> 而非 AActor* 我们在 1.1 节已经提到,这为消息系统引入了极大的灵活度。在 Lyra 里,击杀消息的 InstigatorTarget 填的是 APlayerState*,而不是 ACharacter*APawn*。原因很简单------一个人可能换了角色、Pawn 可能被销毁了,但 PlayerState 贯穿整个游戏过程,是最可靠的玩家标识。

3.1.2 ToString() 的实现------利用反射系统的巧妙操作
cpp 复制代码
// LyraVerbMessageHelpers.cpp
FString FLyraVerbMessage::ToString() const
{
    FString HumanReadableMessage;
    FLyraVerbMessage::StaticStruct()->ExportText(
        HumanReadableMessage, this,
        nullptr, nullptr, PPF_None, nullptr
    );
    return HumanReadableMessage;
}

这其实是一个特别经典的做法。与其手写一个格式化函数把每个字段拼起来------不仅容易遗漏,而且每次加了新字段还得回来改------不如直接用 UE 反射系统的 ExportText()。它会自动遍历所有 UPROPERTY 标记的字段,一锅端地序列化成字符串。调试的时候往日志里一打,一目了然。

3.2 UGameplayMessageProcessor------消息处理器的"骨架"
3.2.1 类设计
cpp 复制代码
// GameplayMessageProcessor.h
UCLASS(BlueprintType, Blueprintable, meta=(BlueprintSpawnableComponent))
class LYRAGAME_API UGameplayMessageProcessor : public UActorComponent
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    virtual void StartListening();
    virtual void StopListening();

protected:
    void AddListenerHandle(FGameplayMessageListenerHandle&& Handle);
    double GetServerTime() const;

private:
    TArray<FGameplayMessageListenerHandle> ListenerHandles;
};

这个类说白了就做了一件事:自动化监听器生命周期管理 。继承自 UActorComponent,意味着它能跟随 Actor 自动启动、自动销毁。BeginPlay 里自动调用 StartListening()EndPlay 里自动注销所有句柄。

3.2.2 生命周期管理详解
cpp 复制代码
// GameplayMessageProcessor.cpp
void UGameplayMessageProcessor::BeginPlay()
{
    Super::BeginPlay();
    StartListening();    // 组件启动时自动开始监听
}

void UGameplayMessageProcessor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);
    StopListening();    // 先通知子类

    UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
    for (FGameplayMessageListenerHandle& Handle : ListenerHandles)
    {
        MessageSubsystem.UnregisterListener(Handle);   // 逐个注销
    }
    ListenerHandles.Empty();
}

这里 StartListening()StopListening() 基类实现是空的------它们是给子类重写的钩子。子类在 StartListening() 里通过 MessageSubsystem.RegisterListener() 注册监听,然后通过 AddListenerHandle(MoveTemp(Handle)) 把句柄交给基类保管。从此以后,子类不需要关心什么时候注销------组件的生命周期结束了,基类的 EndPlay 会自动清理一切。

这就是"骨架模式":"监听开始"和"监听结束"的时机由基类控制,但"监听什么"和"怎么处理"由子类决定。

3.2.3 GetServerTime()------一个容易被忽略但很重要的小工具
cpp 复制代码
double UGameplayMessageProcessor::GetServerTime() const
{
    if (AGameStateBase* GameState = GetWorld()->GetGameState())
    {
        return GameState->GetServerWorldTimeSeconds();
    }
    return 0.0;
}

为什么需要这个方法?考虑连杀检测------需要判断两次击杀的时间间隔。如果用了客户端本地时间,客户端和服务器之间有网络延迟,判定的结果可能不一致。用 GetServerWorldTimeSeconds() 则保证所有人都在同一时间轴上。这个方法放在基类里,所有消息处理器子类都能直接调用,不用每次都写一遍。

3.3 ULyraVerbMessageHelpers------连接消息系统与能力系统的桥梁
3.3.1 对象到玩家身份的智能推断
cpp 复制代码
// LyraVerbMessageHelpers.cpp
APlayerState* ULyraVerbMessageHelpers::GetPlayerStateFromObject(UObject* Object)
{
    if (APlayerController* PC = Cast<APlayerController>(Object))
        return PC->PlayerState;

    if (APlayerState* TargetPS = Cast<APlayerState>(Object))
        return TargetPS;

    if (APawn* TargetPawn = Cast<APawn>(Object))
        if (APlayerState* TargetPS = TargetPawn->GetPlayerState())
            return TargetPS;

    return nullptr;
}

这个函数本质上是一个"尽力而为"的转换器。传入的 Object 可能是 PlayerControllerPlayerStatePawn 中的任何一个------对发送者来说,"是谁"这件事是模糊的。但接收方需要的是一个明确的 PlayerState。于是这个函数按优先级依次尝试:先看是不是 PlayerController,再看是不是 PlayerState 本身,最后尝试从 PawnGetPlayerState() 获取。都试不成就返回 nullptr,让调用方自己处理。

GetPlayerControllerFromObject() 的逻辑完全对称,不再赘述。

3.3.2 与 GameplayCue 的双向转换

Lyra 的 Gameplay Ability System (GAS) 使用 FGameplayCueParameters 来传递技能表现相关的参数。而我们的消息系统用的是 FLyraVerbMessage。两边各有各的字段名和语义,需要一个"翻译官":

cpp 复制代码
FGameplayCueParameters ULyraVerbMessageHelpers::VerbMessageToCueParameters(const FLyraVerbMessage& Message)
{
    FGameplayCueParameters Result;

    Result.OriginalTag = Message.Verb;
    Result.Instigator = Cast<AActor>(Message.Instigator);
    Result.EffectCauser = Cast<AActor>(Message.Target);
    Result.AggregatedSourceTags = Message.InstigatorTags;
    Result.AggregatedTargetTags = Message.TargetTags;
    //@TODO: = Message.ContextTags;
    Result.RawMagnitude = Message.Magnitude;

    return Result;
}

对照表一目了然:

VerbMessage 字段 GameplayCue 字段
Verb OriginalTag
Instigator (UObject) Instigator (AActor)
Target (UObject) EffectCauser (AActor)
InstigatorTags AggregatedSourceTags
TargetTags AggregatedTargetTags
ContextTags @TODO: 未实现
Magnitude RawMagnitude

注意那个 //@TODO: = Message.ContextTags; ------Epic 的开发者也没来得及实现 ContextTags 的转换,说明这个模块还在持续迭代中。反向转换 CueParametersToVerbMessage() 同理,ContextTags 也标了 //@TODO

双向转换的意义在于:能力系统产生的伤害/治疗事件可以通过消息系统流转到 UI 层,而消息系统收到的事件也可以反过来驱动 GameplayCue 播放特效------两个系统之间不再有硬性的单向依赖。

3.4 FLyraVerbMessageReplication------让消息穿越网络
3.4.1 快速数组序列化

这是整个模块里技术含量最高的部分。FFastArraySerializer 是 UE 网络层的一个关键基础设施------它不像普通的 TArray 那样每次复制整个数组,而是只发送变化的部分

cpp 复制代码
// LyraVerbMessageReplication.h
USTRUCT(BlueprintType)
struct FLyraVerbMessageReplicationEntry : public FFastArraySerializerItem
{
    GENERATED_BODY()

    FLyraVerbMessageReplicationEntry() {}
    FLyraVerbMessageReplicationEntry(const FLyraVerbMessage& InMessage) : Message(InMessage) {}

    FString GetDebugString() const;

private:
    friend FLyraVerbMessageReplication;

    UPROPERTY()
    FLyraVerbMessage Message;
};

USTRUCT(BlueprintType)
struct FLyraVerbMessageReplication : public FFastArraySerializer
{
    GENERATED_BODY()

public:
    void SetOwner(UObject* InOwner) { Owner = InOwner; }
    void AddMessage(const FLyraVerbMessage& Message);

    void PreReplicatedRemove(const TArrayView<int32> RemovedIndices, int32 FinalSize);
    void PostReplicatedAdd(const TArrayView<int32> AddedIndices, int32 FinalSize);
    void PostReplicatedChange(const TArrayView<int32> ChangedIndices, int32 FinalSize);

    bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
    {
        return FFastArraySerializer::FastArrayDeltaSerialize<
            FLyraVerbMessageReplicationEntry,
            FLyraVerbMessageReplication
        >(CurrentMessages, DeltaParms, *this);
    }

private:
    void RebroadcastMessage(const FLyraVerbMessage& Message);

private:
    UPROPERTY()
    TArray<FLyraVerbMessageReplicationEntry> CurrentMessages;

    UPROPERTY()
    TObjectPtr<UObject> Owner = nullptr;
};

// 开启 Delta Serialization
template<>
struct TStructOpsTypeTraits<FLyraVerbMessageReplication> : public TStructOpsTypeTraitsBase2<FLyraVerbMessageReplication>
{
    enum { WithNetDeltaSerializer = true };
};

三段关键机制:

AddMessage() 在服务器端被调用

cpp 复制代码
void FLyraVerbMessageReplication::AddMessage(const FLyraVerbMessage& Message)
{
    FLyraVerbMessageReplicationEntry& NewStack = CurrentMessages.Emplace_GetRef(Message);
    MarkItemDirty(NewStack);
}

Emplace_GetRef 原地构造,MarkItemDirty 告诉网络层"这个条目是新加的,下次复制时把它发出去"。

② 客户端收到新消息后自动重广播

cpp 复制代码
void FLyraVerbMessageReplication::PostReplicatedAdd(const TArrayView<int32> AddedIndices, int32 FinalSize)
{
    for (int32 Index : AddedIndices)
    {
        const FLyraVerbMessageReplicationEntry& Entry = CurrentMessages[Index];
        RebroadcastMessage(Entry.Message);
    }
}

void FLyraVerbMessageReplication::RebroadcastMessage(const FLyraVerbMessage& Message)
{
    check(Owner);
    UGameplayMessageSubsystem& MessageSystem = UGameplayMessageSubsystem::Get(Owner);
    MessageSystem.BroadcastMessage(Message.Verb, Message);
}

PostReplicatedAddFFastArraySerializer 的钩子------客户端接收到新增条目后,自动触发。在这个回调里,直接把收到的消息再次广播到客户端的本地 GameplayMessageSubsystem

这意味着什么?客户端代码不必区分消息是"本地产生的"还是"从网络来的"。UI 组件只需要像平常一样注册一个监听器,无论是服务器广播还是客户端本地触发,都能收到。

TStructOpsTypeTraits 特化

cpp 复制代码
template<>
struct TStructOpsTypeTraits<FLyraVerbMessageReplication> : public TStructOpsTypeTraitsBase2<FLyraVerbMessageReplication>
{
    enum { WithNetDeltaSerializer = true };
};

这是 UE 的结构体特性系统------告诉引擎"这个结构体使用自定义的增量序列化"。如果不加这个特化,NetDeltaSerialize 就不会被调用,整个增量复制机制就失效了。

3.5 FLyraNotificationMessage------面向 UI 的专用消息
3.5.1 消息结构
cpp 复制代码
// LyraNotificationMessage.h
LYRAGAME_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Lyra_AddNotification_Message);

USTRUCT(BlueprintType)
struct LYRAGAME_API FLyraNotificationMessage
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite, Category=Notification)
    FGameplayTag TargetChannel;                    // 目标频道

    UPROPERTY(BlueprintReadWrite, Category=Notification)
    TObjectPtr<APlayerState> TargetPlayer = nullptr;  // 目标玩家(空 = 广播)

    UPROPERTY(BlueprintReadWrite, Category=Notification)
    FText PayloadMessage;                          // 文字内容

    UPROPERTY(BlueprintReadWrite, Category=Notification)
    FGameplayTag PayloadTag;                       // 载荷标签

    UPROPERTY(BlueprintReadWrite, Category=Notification)
    TObjectPtr<UObject> PayloadObject = nullptr;   // 载荷对象
};
cpp 复制代码
// LyraNotificationMessage.cpp
UE_DEFINE_GAMEPLAY_TAG(TAG_Lyra_AddNotification_Message, "Lyra.AddNotification.Message");

这个结构体解决的是具体问题:UI 需要不同类型、不同样式、不同目标的消息。FLyraVerbMessage 的"发起者-动词-目标"模型对底层逻辑很友好,但对 UI 显示来说信息不够直接。FLyraNotificationMessage 就是在这个场景下诞生的:

  • TargetChannel:消息要显示在哪个位置------击杀播报?拾取流?状态提醒?
  • TargetPlayer:只给某个特定玩家看,还是所有本地玩家都看?
  • PayloadMessage:直接拿来显示的文字
  • PayloadTagPayloadObject:UI 可以据此决定用什么样式、播放什么音效
3.5.2 频道路由设计

TargetChannel 的妙处在于它的可扩展性。Lyra 里现在定义了 Lyra.ShooterGame.Accolade 这个频道用于荣誉播报:

cpp 复制代码
// LyraAccoladeHostWidget.cpp
UE_DEFINE_GAMEPLAY_TAG_STATIC(TAG_Lyra_ShooterGame_Accolade, "Lyra.ShooterGame.Accolade");

void ULyraAccoladeHostWidget::OnNotificationMessage(FGameplayTag Channel, const FLyraNotificationMessage& Notification)
{
    if (Notification.TargetChannel == TAG_Lyra_ShooterGame_Accolade)
    {
        // 只处理发到 Accolade 频道的消息,其他频道不管
        ...
    }
}

以后如果想加一个新频道------比如装备拾取通知------只需要定义一个新 Tag(比如 Lyra.Inventory.Pickup),然后在对应的 UI Widget 里检查 TargetChannel == TAG_Lyra_Inventory_Pickup 就行了。已有的代码完全不受影响。


4. Lyra 实战:三个真实的代码案例

4.1 案例一:从"角色死亡"到"消息广播"

LyraHealthComponent.cpp(file:///e:/UEProject/LyraStarterGame/Source/LyraGame/Character/LyraHealthComponent.cpp#L148-L188) 里,HandleOutOfHealth 是处理角色血量归零的核心函数。除了触发 GAS 的死亡事件之外,它还向消息系统广播了一条动词消息:

cpp 复制代码
void ULyraHealthComponent::HandleOutOfHealth(AActor* DamageInstigator, AActor* DamageCauser,
    const FGameplayEffectSpec* DamageEffectSpec, float DamageMagnitude, float OldValue, float NewValue)
{
#if WITH_SERVER_CODE
    if (AbilitySystemComponent && DamageEffectSpec)
    {
        // 先走 GAS 的标准流程:发送 GameplayEvent.Death 事件
        {
            FGameplayEventData Payload;
            Payload.EventTag = LyraGameplayTags::GameplayEvent_Death;
            Payload.Instigator = DamageInstigator;
            Payload.Target = AbilitySystemComponent->GetAvatarActor();
            // ...
            AbilitySystemComponent->HandleGameplayEvent(Payload.EventTag, &Payload);
        }

        // 再广播一条标准化的动词消息------其他所有系统都通过这个渠道监听
        {
            FLyraVerbMessage Message;
            Message.Verb = TAG_Lyra_Elimination_Message;
            Message.Instigator = DamageInstigator;
            Message.InstigatorTags = *DamageEffectSpec->CapturedSourceTags.GetAggregatedTags();
            Message.Target = ULyraVerbMessageHelpers::GetPlayerStateFromObject(
                AbilitySystemComponent->GetAvatarActor());
            Message.TargetTags = *DamageEffectSpec->CapturedTargetTags.GetAggregatedTags();

            UGameplayMessageSubsystem& MessageSystem = UGameplayMessageSubsystem::Get(GetWorld());
            MessageSystem.BroadcastMessage(Message.Verb, Message);
        }
    }
#endif
}

这里有三个值得注意的地方:

  1. #if WITH_SERVER_CODE :消息广播只在服务端进行。这样客户端不会重复广播,网络复制走 FLyraVerbMessageReplication

  2. Target 用的是 GetPlayerStateFromObject 的结果 :这保证了消息接收方拿到的 Target 是稳定的 PlayerState 引用,而不是可能已经销毁的 Pawn。

  3. 标签从一个源头流出来InstigatorTagsTargetTags 直接从 DamageEffectSpec 提取,而不是手动构造。这意味着如果在 GAS 中配置了"火焰伤害"标签,这个消息系统自动就能区分"被火烧死"和"被枪打死"。

4.2 案例二:连杀与连杀链------消息处理器的子类实战

UElimStreakProcessor(file:///e:/UEProject/LyraStarterGame/Plugins/GameFeatures/ShooterCore/Source/ShooterCoreRuntime/Public/MessageProcessors/ElimStreakProcessor.h) 作为例子。它继承自 UGameplayMessageProcessor,只重写了 StartListening()

cpp 复制代码
// ElimStreakProcessor.h
UCLASS(Abstract)
class UElimStreakProcessor : public UGameplayMessageProcessor
{
    GENERATED_BODY()

public:
    virtual void StartListening() override;

protected:
    UPROPERTY(EditDefaultsOnly)
    TMap<int32, FGameplayTag> ElimStreakTags;  // 阈值Map:{3 → TripleKill, 5 → MegaKill}

private:
    void OnEliminationMessage(FGameplayTag Channel, const FLyraVerbMessage& Payload);

    UPROPERTY(Transient)
    TMap<TObjectPtr<APlayerState>, int32> PlayerStreakHistory;  // 运行时数据
};

// ElimStreakProcessor.cpp
void UElimStreakProcessor::StartListening()
{
    UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
    AddListenerHandle(
        MessageSubsystem.RegisterListener(
            ElimStreak::TAG_Lyra_Elimination_Message,
            this,
            &ThisClass::OnEliminationMessage
        )
    );
}

void UElimStreakProcessor::OnEliminationMessage(FGameplayTag Channel, const FLyraVerbMessage& Payload)
{
    if (Payload.Instigator != Payload.Target)       // 排除自杀
    {
        if (APlayerState* InstigatorPS = Cast<APlayerState>(Payload.Instigator))
        {
            int32& StreakCount = PlayerStreakHistory.FindOrAdd(InstigatorPS);
            StreakCount++;

            if (FGameplayTag* pTag = ElimStreakTags.Find(StreakCount))
            {
                FLyraVerbMessage ElimStreakMessage;
                ElimStreakMessage.Verb = *pTag;                 // 新的Verb:TripleKill等
                ElimStreakMessage.Instigator = InstigatorPS;
                ElimStreakMessage.InstigatorTags = Payload.InstigatorTags;
                ElimStreakMessage.ContextTags = Payload.ContextTags;
                ElimStreakMessage.Magnitude = StreakCount;

                UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
                MessageSubsystem.BroadcastMessage(ElimStreakMessage.Verb, ElimStreakMessage);
            }
        }
    }

    // 被击杀者连杀清零
    if (APlayerState* TargetPS = Cast<APlayerState>(Payload.Target))
    {
        PlayerStreakHistory.Remove(TargetPS);
    }
}

这个例子里最精彩的部分在于:消息处理器不光消费消息,它自己也生产消息。 这是一个典型的级联模式:

HealthComponent 广播 Lyra.Elimination.Message

→ StreakProcessor 收到,累计连杀数

→ 达到阈值,广播新的 Lyra.ShooterGame.Streak.TripleKill 等消息

→ UI 系统收到,显示击杀播报

每一层只关心自己那一层的逻辑。HealthComponent 不知道"连杀"的存在,UI 系统不知道"血量归零"的细节。但所有的系统通过统一的消息格式串联成了一条完整的功能链。

UElimChainProcessor 的逻辑几乎一样,唯一的区别是多了一个时间窗口判定(ChainTimeLimit = 4.5 秒,超时则连杀链归零)。

4.3 案例三:通知消息驱动 UI------荣誉播报

LyraAccoladeHostWidget(file:///e:/UEProject/LyraStarterGame/Plugins/GameFeatures/ShooterCore/Source/ShooterCoreRuntime/Private/Accolades/LyraAccoladeHostWidget.cpp) 是 FLyraNotificationMessage 的典型消费者:

cpp 复制代码
void ULyraAccoladeHostWidget::NativeConstruct()
{
    Super::NativeConstruct();
    UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
    ListenerHandle = MessageSubsystem.RegisterListener(
        TAG_Lyra_AddNotification_Message,
        this,
        &ThisClass::OnNotificationMessage
    );
}

void ULyraAccoladeHostWidget::NativeDestruct()
{
    UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
    MessageSubsystem.UnregisterListener(ListenerHandle);
    Super::NativeDestruct();
}

void ULyraAccoladeHostWidget::OnNotificationMessage(FGameplayTag Channel, const FLyraNotificationMessage& Notification)
{
    if (Notification.TargetChannel == TAG_Lyra_ShooterGame_Accolade)
    {
        // 如果指定了目标玩家但不是当前玩家,忽略
        if (Notification.TargetPlayer != nullptr)
        {
            APlayerController* PC = GetOwningPlayer();
            if ((PC == nullptr) || (PC->PlayerState != Notification.TargetPlayer))
            {
                return;
            }
        }

        // 通过 DataRegistry 异步加载播报定义
        FDataRegistryId ItemID(NAME_AccoladeRegistryID, Notification.PayloadTag.GetTagName());
        UDataRegistrySubsystem::Get()->AcquireItem(ItemID, ...);
    }
}

整体流程是:某个系统(比如成就系统)构造一个 FLyraNotificationMessage,设置好 TargetChannelTargetPlayer(可选)、PayloadTag(播报类型标识),然后通过 TAG_Lyra_AddNotification_Message 广播。UI Widget 收到后检查频道匹配、检查目标玩家匹配,通过后从 DataRegistry 加载播报资源并显示。


5. 深入背后:几个不那么明显但值得聊的设计决策

5.1 为什么消息的 Verb 用 FGameplayTag 而不是枚举?

用枚举当然也行,但 FGameplayTag 有两点枚举做不到的好处:

  1. 运行时扩展 :DLC 或 Mod 可以在不重新编译 C++ 的情况下定义新的消息标签。比如加了"暴击击杀"这个新事件,策划在配置里定义一个 MyGame.Combat.CriticalKill 标签,代码就能用了。

  2. 层级匹配 :GameplayTag 的层级结构天然支持"通配符匹配"。虽然 Lyra 现在没用到,但设计上预留了这个可能性------比如可以监听 Lyra.Elimination.* 来捕获所有击杀相关消息。

5.2 为什么 Target / Instigator 用 UObject* 而不是模板化?

模板化的方案大概是:template<typename InstigatorType, typename TargetType> struct TLyraVerbMessage {...}。看起来更类型安全,但实际上会带来三个问题:

  • 每一种 (InstigatorType, TargetType) 组合都是一条新的消息类型,需要在 GameplayMessageSubsystem 里分别注册
  • 失去了"统一消息流"的优势------中间层(如连杀检测)需要知道具体的类型才能处理
  • 模板实例化膨胀,编译时间和二进制体积都会增加

UObject* + 运行时 Cast 的方案虽然没那么"类型安全",但对于游戏这种对灵活性要求极高的场景来说,是更务实的取舍。

5.3 为什么消息处理器放在 ActorComponent 而不是 Subsystem?

用 Subsystem 当然也可以------实际上 UGameplayMessageSubsystem 自己就是 Subsystem。但 UGameplayMessageProcessor 选择作为 Component 是有原因的:

  • Component 天然跟 Actor 的生命周期绑定。一个 Actor 死了,它上面的所有 Component 跟着销毁,监听器也跟着注销。不需要手动管理。
  • Component 可以通过配置决定放在哪个 Actor 上。比如连杀处理器放在 GameState 上(服务器全局唯一),击杀播报处理器放在 PlayerController 上(每个玩家一个)。

6. 如果把 Messages 模块迁移到自己的项目

整个模块只有六个源文件,依赖关系也非常干净:

依赖 用途
Core/CoreUObject/Engine 基础引擎模块
GameplayMessageSubsystem 消息总线
GameplayTags 消息类型标识和标签容器
FFastArraySerializer 网络增量复制
GameplayEffectTypes 与 GAS 的 GameplayCue 互转(仅 Helpers)

迁移建议:

  1. 核心FLyraVerbMessage + UGameplayMessageProcessor 是最小的可用子集。拿了这两个就能开始用消息系统了。

  2. 可选 :如果不需要网络消息广播(比如单机游戏),FLyraVerbMessageReplication 可以省略。

  3. 可选FLyraNotificationMessage 是面向 UI 的扩展,如果有类似的"多频道 UI 通知"需求,直接拿来用;如果 UI 层有自己的通知系统,可以跳过。

  4. 可选ULyraVerbMessageHelpers 如果不用 GAS 的 GameplayCue,只需要保留 GetPlayerStateFromObjectGetPlayerControllerFromObject 两个函数。


7. 潜在优化点

完整不是终点。以下几个方向是在现有基础上值得考虑的:

  1. 消息过期与清理FLyraVerbMessageReplication::CurrentMessages 数组只会增长不会缩小。在长时间运行的服务器上,可以考虑添加 TTL 机制,定期清理旧消息。

  2. ContextTags 的完善VerbMessageToCueParametersCueParametersToVerbMessage 中的 ContextTags 转换都标了 @TODO。如果项目里上下文信息很重要,这个该优先补齐。

  3. 消息处理器优先级:多个处理器监听同一条消息时,执行顺序目前是不确定的。如果需要保证顺序(比如连杀检测必须在 UI 播报之前执行),可以考虑加入优先级机制。

  4. 弱引用保护FLyraVerbMessageInstigatorTarget 用的是 TObjectPtr(强引用)。如果消息本身被某个对象长期持有(比如存在数组里),即使玩家已经离线,对应的 PlayerState 也会被强引用钉在内存里。在长时间持有消息的场景下,可以考虑用 TWeakObjectPtr

  5. PreReplicatedRemove 的实现 :目前 FLyraVerbMessageReplication::PreReplicatedRemove 里的代码是注释掉的,是从其他代码里拷贝的模板。如果未来需要追踪消息的移除,这里需要重新实现。


写在最后

Messages 模块在 Lyra 里不是最"炫"的模块------它不像技能系统那样有一堆复杂的 GameplayEffect 配置,也不像武器系统那样有大量关于子弹散布和弹道预测的代码。但它是 连接一切的胶水

一条击杀消息从 LyraHealthComponentHandleOutOfHealth 出发,流经 UGameplayMessageSubsystem,被 UElimStreakProcessor 拦截、加工、重新广播,再通过网络复制抵达客户端,最后在 ULyraAccoladeHostWidget 里变成一个荣誉播报------这个过程中,每一个环节都不知道彼此的存在,却配合得天衣无缝。

几个值得带走的重点:

  • FLyraVerbMessage 的"发起者-动词-目标"模型是核心。它统一了整个游戏的事件接口,让所有系统说同一种语言。
  • UGameplayMessageProcessor 的价值在于自动化------监听器的注册和注销跟组件的生命周期自动绑定,子类只需要关心"监听什么"和"怎么响应"。
  • FLyraVerbMessageReplication 让网络消息对客户端完全透明。客户端代码不需要区分消息的来源,写法完全统一。
  • 消息处理器的级联模式是 Lyra 里用得最漂亮的设计------一个处理器消费消息、产出新消息,像链条一样把功能串联起来。
  • 代码量很小,但架构成熟。六个文件就能建立起一个完整的发布-订阅消息体系,迁移成本低,收益高。

希望这篇分析能帮助大家在自己的项目里更自信地使用这套消息机制。

相关推荐
Ivan CloudBay2 小时前
SDK 游戏盾如何隐藏游戏服务器的真实 IP?
服务器·tcp/ip·游戏
魔术师Dix2 小时前
StartGame:Unity TDD 外部工程指南
学习·游戏·unity·c#·测试驱动开发
我要见SA姐112 小时前
AI提示词遇见精密算法:TimeGuessr如何用数学魔法打造文化游戏新体验
人工智能·算法·游戏
星空露珠20 小时前
迷你世界3.0API,事件监听
开发语言·数据结构·数据库·游戏·lua
日月云棠1 天前
UE5 Lyra Input 模块:从手柄摇杆到技能释放,一条链路如何做到零耦合
ue5
想做后端的前端2 天前
WebGL实现FPS游戏
vue.js·游戏·webgl
EanoJiang2 天前
【UE】混合程序动画
游戏
河南花仙子科技2 天前
花仙子科技干货分享|小游戏版权归属问题科普
科技·游戏