[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

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

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

输入处理在 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 的处理函数自动被调用,只重绘那一格,不全量刷新。
这种"事件驱动、不轮询"的模式有三个好处:
- 零开销时不干活:背包没变化,UI 代码完全不运行。
- 精准刷新:只有变化的那一项触发重绘,而不是每帧 Tick 所有格子。
- 解耦:数据层(Controller)不知道有多少 UI 在看着它,UI 层也不持有 Controller 引用(通过接口取委托)。
四、URPGBlueprintLibrary:蓝图函数库的桥梁作用
URPGBlueprintLibrary 是一个 UBlueprintFunctionLibrary,收录了若干纯工具函数,让蓝图可以调用那些"不属于任何特定类"的能力:
| 函数 | 类别 | 作用 |
|---|---|---|
PlayLoadingScreen / StopLoadingScreen | 加载屏 | 跨模块调用 LoadingScreen 模块的接口 |
IsInEditor | 工具 | 判断是否在编辑器预览,排除 PIE 的特殊逻辑 |
EqualEqual_RPGItemSlot / NotEqual_RPGItemSlot | 背包 | 让蓝图能用 == != 比较 FRPGItemSlot(struct 需要手动暴露运算符) |
IsValidItemSlot | 背包 | 检查槽位是否有效(ItemType 和 SlotNumber 都合法) |
DoesEffectContainerSpecHaveEffects / HasValidTargets | GAS | 蓝图里判断 ContainerSpec 有没有数据 |
AddTargetsToEffectContainerSpec | GAS | 外部蓝图(非 Ability 内)给 Spec 填目标 |
ApplyExternalEffectContainerSpec | GAS | 外部蓝图(非 Ability 内)施加 Spec |
GetProjectVersion | 项目 | 读取项目设置里的版本号,用于 UI 显示 |
特别值得注意的是 AddTargetsToEffectContainerSpec 和 ApplyExternalEffectContainerSpec:这两个函数把 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();
}
};
StartupModule 在 PreLoadingScreen 阶段被调用——此时就把 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 UMG | Slate 是引擎底层、无反射依赖,适合极早阶段;UMG 依赖 UObject 体系,需要主模块已加载 |
ActionRPG 从 C++ 的骨架到蓝图的血肉,每一层都有清晰的职责边界。理解了这条"C++ → 蓝图 → UI → 加载屏"的分层逻辑,你就能在自己的项目里做出同样层次清晰的设计。

347

被折叠的 条评论
为什么被折叠?



