总览
项目真正落地时,一定会写自定义 Action:加 UI、注册消息监听、授予任务、接相机模式、注册商店入口、注入 GameplayCue 路径。写 Action 的标准不是“能激活”,而是重复激活不重复、停用能清干净、多世界不串、异步不悬空。
使用案例:AddReticleWidget
Rifle 武器包激活后,需要在 HUD 上显示准星。主工程不应该永远内置所有武器准星,武器包也不应该直接改主 HUD 蓝图。更稳的方式是写一个 Add Reticle Widget Action:激活时向项目 UI 扩展点注册准星 Widget,停用时注销。
示例:AddReticleWidget
目标:武器包激活后,给本地玩家 HUD 加一个准星 Widget;停用后移除。
USTRUCT()
struct FReticleWidgetEntry
{
GENERATED_BODY()
UPROPERTY(EditAnywhere)
TSoftClassPtr<APlayerController> PlayerControllerClass;
UPROPERTY(EditAnywhere, meta=(AssetBundles="Client"))
TSoftClassPtr<UUserWidget> WidgetClass;
UPROPERTY(EditAnywhere)
FGameplayTag SlotTag;
};
UCLASS(meta=(DisplayName="Add Reticle Widget"))
class UGameFeatureAction_AddReticleWidget : public UGameFeatureAction
{
GENERATED_BODY()
public:
virtual void OnGameFeatureActivating(FGameFeatureActivatingContext& Context) override;
virtual void OnGameFeatureDeactivating(FGameFeatureDeactivatingContext& Context) override;
#if WITH_EDITOR
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
#endif
private:
UPROPERTY(EditAnywhere)
TArray<FReticleWidgetEntry> Widgets;
struct FPerContextData
{
TArray<TWeakObjectPtr<UUserWidget>> AddedWidgets;
TArray<FDelegateHandle> DelegateHandles;
};
TMap<FGameFeatureStateChangeContext, FPerContextData> ContextData;
};
这只是骨架,关键是 FPerContextData。每次激活按 Context 保存自己加过的东西,停用时只清理自己那份。
Activating 怎么写
激活阶段:
- 遍历
GEngine->GetWorldContexts()。 - 用
Context.ShouldApplyToWorldContext(WorldContext)过滤。 - 确认
World->IsGameWorld()。 - 找本地 PlayerController/HUD/UI Extension。
- 创建或注册 Widget。
- 保存 widget 指针或注册 handle。
不要在一个全局数组里保存所有 Widget,也不要假设只有一个 World。
Deactivating 怎么写
停用阶段:
void UGameFeatureAction_AddReticleWidget::OnGameFeatureDeactivating(FGameFeatureDeactivatingContext& Context)
{
if (FPerContextData* ActiveData = ContextData.Find(Context))
{
for (TWeakObjectPtr<UUserWidget>& Widget : ActiveData->AddedWidgets)
{
if (UUserWidget* StrongWidget = Widget.Get())
{
StrongWidget->RemoveFromParent();
}
}
ActiveData->AddedWidgets.Reset();
ActiveData->DelegateHandles.Reset();
}
ContextData.Remove(Context);
}
如果 UI 有退场动画,可以用 PauseDeactivationUntilComplete,动画完成后再调用 delegate。但不要在 Shipping 路径里无限等待。
Data Validation
#if WITH_EDITOR
EDataValidationResult UGameFeatureAction_AddReticleWidget::IsDataValid(FDataValidationContext& Context) const
{
EDataValidationResult Result = EDataValidationResult::Valid;
for (int32 Index = 0; Index < Widgets.Num(); ++Index)
{
if (Widgets[Index].WidgetClass.IsNull())
{
Context.AddError(FText::Format(
NSLOCTEXT("GameFeatures", "MissingWidget", "WidgetClass is missing at index {0}."),
FText::AsNumber(Index)));
Result = EDataValidationResult::Invalid;
}
}
return Result;
}
#endif
自定义 Action 是给团队配置的,不校验就等于把错误推到运行时。
AddAdditionalAssetBundleData
如果 Action 引用资源,要考虑 bundle:
#if WITH_EDITORONLY_DATA
void UGameFeatureAction_AddReticleWidget::AddAdditionalAssetBundleData(FAssetBundleData& AssetBundleData)
{
for (const FReticleWidgetEntry& Entry : Widgets)
{
AssetBundleData.AddBundleAsset(
UGameFeaturesSubsystemSettings::LoadStateClient,
Entry.WidgetClass.ToSoftObjectPath().GetAssetPath());
}
}
#endif
这样 Cook 和预加载才知道 Widget 属于 Client bundle。
架构分析
自定义 Action 的本质是一条“可撤销命令”。它可以注册东西、创建东西、授予东西,但必须记录自己做过什么。FPerContextData 是核心:同一个插件可能在不同 WorldContext 下激活,PIE、多世界、服务器旅行都会暴露全局状态的错误。所有 delegate handle、widget handle、ability handle、request handle 都应该按 Context 保存。
常见坑
ContextData不按 Context 存,多 PIE 或多世界互相清理。- Delegate 只绑定不解绑。
- Deactivate 里 Remove 全部同类 Widget,把别的玩法包 UI 也删了。
- 异步加载完成时插件已经停用,没有检查 WeakThis 或 ActiveData。
- Editor Data Validation 缺失,策划填空资源后运行时才报错。
源码依据
UGameFeatureAction 支持 context 版 Activating/Deactivating。Lyra 的多个自定义 Action 使用 TMap<FGameFeatureStateChangeContext, FPerContextData> 保存激活数据,并在 Deactivate 中 Reset。FGameFeatureDeactivatingContext 提供 PauseDeactivationUntilComplete 支持异步停用收尾。
源码路径索引
GameFeatures/Public/GameFeatureAction.hGameFeatures/Public/GameFeaturesSubsystem.hSamples/Games/Lyra/Source/LyraGame/GameFeatures/GameFeatureAction_WorldActionBase.hSamples/Games/Lyra/Source/LyraGame/GameFeatures/GameFeatureAction_AddWidget.hGameFeatures/Public/GameFeatureData.h