【UE源码精读-ActionRPG】蓝图角色实践与UI事件驱动

[TOC]

C++ 提供骨架,蓝图填充血肉——这是 ActionRPG 整个代码架构的基调。前几篇精读的都是 C++ 层;这一篇视角下沉:看蓝图角色如何把 C++ 接口组装成实际游戏逻辑,看 UI 如何靠委托事件驱动自动刷新,以及一个独立模块 ActionRPGLoadingScreen 如何解决"鸡和蛋"的初始化顺序问题。


一、C++ 与蓝图的职责边界

ARPGCharacterBase 是 C++ 提供的接口层,它对蓝图暴露了两类东西:

1. BlueprintCallable:C++ 实现,蓝图调用

// 激活槽位对应的能力
bool ActivateAbilitiesWithItemSlot(FRPGItemSlot ItemSlot, bool bAllowRemoteActivation);
// 查询血量等属性
float GetHealth() const;
float GetMaxHealth() const;
// 获取冷却剩余时间
bool GetCooldownRemainingForTag(FGameplayTagContainer, float&, float&);

2. BlueprintImplementableEvent:蓝图实现,C++ 调用

// C++ 触发,蓝图里实现受击特效/死亡逻辑
void OnDamaged(float, const FHitResult&, const FGameplayTagContainer&, ...);
void OnHealthChanged(float DeltaValue, const FGameplayTagContainer&);
void OnManaChanged(float DeltaValue, const FGameplayTagContainer&);

这条边界的意义是:游戏逻辑数据(谁打了谁、伤了多少)由 C++ 负责算;游戏表现效果(播什么动画、显示什么特效、判断死亡触发什么表现)由蓝图负责实现。程序员不需要知道美术做了多少受击特效变体,策划不需要在 C++ 里改死亡判定。


二、BP_PlayerCharacter:蓝图里的攻击逻辑

BP_PlayerCharacter 继承 ARPGCharacterBase,在蓝图里重写了一批关键方法。

2.1 DoMeleeAttack

BP_PlayerCharacter 的 DoMeleeAttack 节点:调用 ActivateAbilitiesWithItemSlot

近战攻击直接调 ActivateAbilitiesWithItemSlot,传入当前武器的槽位。C++ 那边从 SlottedAbilities 查到对应的 FGameplayAbilitySpecHandle,调 TryActivateAbility——整个 GAS 激活链就此启动。蓝图里这几行节点,对应的是 C++ 里的 ActivateAbilitiesWithItemSlotASC::TryActivateAbility

2.2 DoSkillAttack

BP_PlayerCharacter 的 DoSkillAttack:激活技能槽的能力

技能攻击和近战攻击逻辑相同,只是传入的是 Skill 类型的槽位。背包系统精读里讲过 FRPGItemSlotItemTypeSlotNumber 构成——Weapon Slot 0 和 Skill Slot 0 是不同的槽,对应不同的能力授予。

2.3 InputAction NormalAttack

玩家按攻击键的输入处理:调用 DoMeleeAttack

输入处理在 BP_PlayerCharacter 里绑定(通过 PlayerInputComponent 绑定 InputAction),收到输入后调用 DoMeleeAttack。从这里到最终 GAS 扣血,是这一系列文章梳理的完整路径。

2.4 InputAction ChangeWeapon

在这里插入图片描述

换武器流程:更新 CurrentWeaponSlot 变量,调用 Controller 上的 SetSlottedItem(背包系统),触发 OnSlottedItemChanged 委托,进而触发 RefreshSlottedGameplayAbilities——能力系统的增量刷新就此启动。

2.5 CurrentWeapon 引用

在这里插入图片描述

BP_PlayerCharacter 里有一个 CurrentWeapon 变量,持有当前生成在手上的 WeaponActor。武器切换时,旧的 WeaponActor 销毁,新的生成并附加到角色骨骼插槽。这是纯表现层的逻辑——即使 CurrentWeapon 为空,GAS 层的能力授予也正常运行,两个层完全独立。


三、UI 事件驱动刷新模式

ActionRPG 的 UI 不轮询——血条、背包显示从不每帧查询数据,而是订阅委托,数据变时自动刷新

3.1 委托声明

回到 RPGTypes.h,每个数据变化都有一对委托:

// 背包物品变化
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInventoryItemChanged, bool, bAdded, URPGItem*, Item);
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnInventoryItemChangedNative, bool, bAdded, URPGItem*, Item);

// 槽位物品变化
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSlottedItemChanged, FRPGItemSlot, ItemSlot, URPGItem*, Item);
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnSlottedItemChangedNative, FRPGItemSlot, ItemSlot, URPGItem*, Item);

Dynamic 版可以在蓝图里绑定(Widget 里绑定处理函数);Native 版用于 C++ 快速订阅(角色订阅槽位变化去刷新能力)。两者在 NotifyInventoryItemChanged / NotifySlottedItemChanged 里依次广播:

void ARPGPlayerControllerBase::NotifyInventoryItemChanged(bool bAdded, URPGItem* Item)
{
    OnInventoryItemChangedNative.Broadcast(bAdded, Item);  // C++ 先收到
    OnInventoryItemChanged.Broadcast(bAdded, Item);        // 蓝图/UI 后收到
    InventoryItemChangedBP(bAdded, Item);                  // BlueprintImplementableEvent
}

3.2 UI Widget 绑定委托

在主界面 Widget 的 EventConstruct 里,通过 GetPlayerController → Cast → Bind Event to OnInventoryItemChanged 把 Widget 的处理函数绑上去。此后每次背包变化,Widget 的处理函数自动被调用,只重绘那一格,不全量刷新。

这种"事件驱动、不轮询"的模式有三个好处:

  1. 零开销时不干活:背包没变化,UI 代码完全不运行。
  2. 精准刷新:只有变化的那一项触发重绘,而不是每帧 Tick 所有格子。
  3. 解耦:数据层(Controller)不知道有多少 UI 在看着它,UI 层也不持有 Controller 引用(通过接口取委托)。

四、URPGBlueprintLibrary:蓝图函数库的桥梁作用

URPGBlueprintLibrary 是一个 UBlueprintFunctionLibrary,收录了若干纯工具函数,让蓝图可以调用那些"不属于任何特定类"的能力:

函数类别作用
PlayLoadingScreen / StopLoadingScreen加载屏跨模块调用 LoadingScreen 模块的接口
IsInEditor工具判断是否在编辑器预览,排除 PIE 的特殊逻辑
EqualEqual_RPGItemSlot / NotEqual_RPGItemSlot背包让蓝图能用 == != 比较 FRPGItemSlot(struct 需要手动暴露运算符)
IsValidItemSlot背包检查槽位是否有效(ItemType 和 SlotNumber 都合法)
DoesEffectContainerSpecHaveEffects / HasValidTargetsGAS蓝图里判断 ContainerSpec 有没有数据
AddTargetsToEffectContainerSpecGAS外部蓝图(非 Ability 内)给 Spec 填目标
ApplyExternalEffectContainerSpecGAS外部蓝图(非 Ability 内)施加 Spec
GetProjectVersion项目读取项目设置里的版本号,用于 UI 显示

特别值得注意的是 AddTargetsToEffectContainerSpecApplyExternalEffectContainerSpec:这两个函数把 FRPGGameplayEffectContainerSpec 的使用权开放给"不在某个 Ability 内"的蓝图——比如一个游戏外触发的范围爆炸,不是由某个 URPGGameplayAbility 实例发起,但需要对周围目标施加 GE,就可以通过这个函数库从外部完成。


五、ActionRPGLoadingScreen:模块加载顺序的鸡与蛋

加载屏是整个项目里最容易被忽视、但工程上最有趣的一块。

5.1 为什么单独做一个模块

ActionRPG.uproject 里有这样的配置:

{
  "Name": "ActionRPGLoadingScreen",
  "Type": "ClientOnly",
  "LoadingPhase": "PreLoadingScreen"
}

PreLoadingScreen 阶段是引擎最早期的初始化阶段——此时主游戏模块(ActionRPG还没有加载。如果把加载屏放在主模块里,就会出现"鸡和蛋"的问题:加载屏需要等主模块加载才能显示,而主模块加载就是加载屏需要覆盖的那段时间。

解决方法:单独抽一个模块,并指定它比主模块先加载。这个模块不能依赖主模块里的任何类型,所以 ActionRPGLoadingScreen.h 只定义了一个极简接口 IActionRPGLoadingScreenModule

5.2 为什么用 Slate 而不是 UMG

class SRPGLoadingScreen : public SCompoundWidget
{
    void Construct(const FArguments& InArgs)
    {
        // 用 Slate 手写布局:背景色 + 居中 Logo + 右下角 Throbber
        ChildSlot
        [
            SNew(SOverlay)
            + SOverlay::Slot().HAlign(HAlign_Fill).VAlign(VAlign_Fill)
            [
                SNew(SBorder).BorderImage(BGBrush)
            ]
            + SOverlay::Slot().HAlign(HAlign_Center).VAlign(VAlign_Center)
            [
                SNew(SImage).Image(LoadingScreenBrush.Get())
            ]
            + SOverlay::Slot().HAlign(HAlign_Fill).VAlign(VAlign_Fill)
            [
                SNew(SVerticalBox)
                + SVerticalBox::Slot().VAlign(VAlign_Bottom).HAlign(HAlign_Right)
                [
                    SNew(SThrobber).Visibility(this, &SRPGLoadingScreen::GetLoadIndicatorVisibility)
                ]
            ]
        ];
    }
};

UMG(Unreal Motion Graphics)依赖 UObject 和 UE 的反射系统,这两者要在主模块加载后才可用。PreLoadingScreen 阶段只有引擎最底层的 Slate UI 框架可用。因此,加载屏只能用 Slate 手写,而不能用 UMG Widget Blueprint。

这也是为什么 ActionRPGLoadingScreen 不能包含任何带 UCLASS/USTRUCT 宏的类——那些宏需要 UHT(Unreal Header Tool)在主模块编译阶段处理。

5.3 StartupModule 的初始化时机

class FActionRPGLoadingScreenModule : public IActionRPGLoadingScreenModule
{
    virtual void StartupModule() override
    {
        // 强制加载 Logo 贴图(为 Cook 引用,不让 Cooker 剔除)
        LoadObject<UObject>(nullptr, TEXT("/Game/UI/T_ActionRPG_TransparentLogo..."));

        if (IsMoviePlayerEnabled())
            CreateScreen();
    }
};

StartupModulePreLoadingScreen 阶段被调用——此时就把 SRPGLoadingScreen 设置进 MoviePlayer,后续主模块加载期间,MoviePlayer 负责在渲染线程持续显示这个 Slate Widget,直到加载完成。

LoadObject<UObject> 那行是 Cook 的需要:Logo 贴图没有被硬引用(没有 UPROPERTY 指向它),如果不在这里主动加载一次,Cooker 会认为它没被使用而把它剔除出包,打包后加载屏就变成黑屏。

5.4 游戏内加载屏(跨关卡时)

除了启动时,切换关卡时也需要加载屏:

virtual void StartInGameLoadingScreen(bool bPlayUntilStopped, float PlayTime) override
{
    FLoadingScreenAttributes LoadingScreen;
    LoadingScreen.bAutoCompleteWhenLoadingCompletes = !bPlayUntilStopped;
    LoadingScreen.bWaitForManualStop = bPlayUntilStopped;
    LoadingScreen.MinimumLoadingScreenDisplayTime = PlayTime;
    LoadingScreen.WidgetLoadingScreen = SNew(SRPGLoadingScreen);
    GetMoviePlayer()->SetupLoadingScreen(LoadingScreen);
}

蓝图通过 URPGBlueprintLibrary::PlayLoadingScreen 调到这里,再通过 StopLoadingScreen 关闭。模块接口在两个模块之间充当了"防火墙":蓝图代码在主模块里,加载屏代码在另一个模块里,两者只通过 IActionRPGLoadingScreenModule 接口交流,主模块不依赖 ActionRPGLoadingScreen 的任何实现细节。


六、小结:蓝图层的设计哲学

话题核心观点
C++ 与蓝图边界BlueprintCallable = C++算,蓝图用;BlueprintImplementableEvent = C++触发,蓝图实现
UI 刷新模式订阅委托,事件驱动,不轮询。变化时精准刷新,静默时零开销
蓝图函数库收录"不属于任何类"的工具,让蓝图能调用跨类、跨模块的能力
LoadingScreen 独立模块PreLoadingScreen 阶段只有 Slate 可用;用单独模块先于主模块加载,解决初始化顺序的鸡与蛋
Slate vs UMGSlate 是引擎底层、无反射依赖,适合极早阶段;UMG 依赖 UObject 体系,需要主模块已加载

ActionRPG 从 C++ 的骨架到蓝图的血肉,每一层都有清晰的职责边界。理解了这条"C++ → 蓝图 → UI → 加载屏"的分层逻辑,你就能在自己的项目里做出同样层次清晰的设计。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夜猫逐梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值