下面给你一套 轻量版 UE 角色技能系统 :
C++ 负责技能状态、蓝耗、冷却和校验;Blueprint 负责真正的技能表现 ,比如放动画、生成投射物、冲刺、播特效。这个分工符合 UE 官方推荐的 C++ + Blueprint 混合开发 方式;同时 ActorComponent 本来就是给 Actor 挂载可复用行为用的,很适合拿来做角色技能系统。若后面要做复杂属性、效果堆叠、多人预测/复制,官方有更完整的 Gameplay Ability System(GAS) 。(Epic Games Developers)
先说明边界:这套代码是 非 GAS、可直接上手的最小可用版 ,适合单机、局域网原型、动作游戏原型、RPG 原型。多人正式项目通常会继续加 RPC、属性复制,或者直接切 GAS。UE 官方文档也明确把 GAS 定位成处理技能、属性、效果和技能任务的完整框架。(Epic Games Developers)
1) SkillTypes.h
cpp
#pragma once
#include "CoreMinimal.h"
#include "SkillTypes.generated.h"
USTRUCT(BlueprintType)
struct FSkillSpec
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Skill")
FName SkillId = NAME_None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Skill", meta=(ClampMin="0.0"))
float ManaCost = 10.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Skill", meta=(ClampMin="0.0"))
float Cooldown = 2.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Skill")
bool bUnlocked = true;
};
2) SkillSystemComponent.h
cpp
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "SkillTypes.h"
#include "SkillSystemComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnManaChangedSignature, float, NewMana);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSkillCastSignature, FName, SkillId);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSkillFailedSignature, FName, SkillId, FString, Reason);
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class YOURPROJECT_API USkillSystemComponent : public UActorComponent
{
GENERATED_BODY()
public:
USkillSystemComponent();
protected:
virtual void BeginPlay() override;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Skill|Config")
float MaxMana = 100.0f;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Skill|State")
float CurrentMana = 100.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Skill|Config")
TArray<FSkillSpec> Skills;
// 记录每个技能冷却结束的绝对时间
UPROPERTY()
TMap<FName, float> CooldownEndTimes;
public:
UPROPERTY(BlueprintAssignable, Category="Skill|Events")
FOnManaChangedSignature OnManaChanged;
UPROPERTY(BlueprintAssignable, Category="Skill|Events")
FOnSkillCastSignature OnSkillCast;
UPROPERTY(BlueprintAssignable, Category="Skill|Events")
FOnSkillFailedSignature OnSkillFailed;
UFUNCTION(BlueprintCallable, Category="Skill")
bool TryCastSkill(FName SkillId);
UFUNCTION(BlueprintCallable, Category="Skill")
bool CanCastSkill(FName SkillId, FString& OutReason) const;
UFUNCTION(BlueprintPure, Category="Skill")
bool HasSkill(FName SkillId) const;
UFUNCTION(BlueprintPure, Category="Skill")
float GetRemainingCooldown(FName SkillId) const;
UFUNCTION(BlueprintPure, Category="Skill")
float GetCurrentMana() const { return CurrentMana; }
UFUNCTION(BlueprintPure, Category="Skill")
float GetMaxMana() const { return MaxMana; }
UFUNCTION(BlueprintCallable, Category="Skill")
void RestoreMana(float Amount);
UFUNCTION(BlueprintCallable, Category="Skill")
void SetMana(float NewMana);
UFUNCTION(BlueprintCallable, Category="Skill")
void AddOrUpdateSkill(const FSkillSpec& NewSkill);
// 真正的技能效果交给 Blueprint 实现
UFUNCTION(BlueprintImplementableEvent, Category="Skill|Events")
void BP_ExecuteSkill(FName SkillId);
UFUNCTION(BlueprintImplementableEvent, Category="Skill|Events")
void BP_OnSkillFailed(FName SkillId, const FString& Reason);
private:
const FSkillSpec* FindSkillSpec(FName SkillId) const;
};
3) SkillSystemComponent.cpp
cpp
#include "SkillSystemComponent.h"
USkillSystemComponent::USkillSystemComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}
void USkillSystemComponent::BeginPlay()
{
Super::BeginPlay();
CurrentMana = FMath::Clamp(CurrentMana, 0.0f, MaxMana);
OnManaChanged.Broadcast(CurrentMana);
}
const FSkillSpec* USkillSystemComponent::FindSkillSpec(FName SkillId) const
{
return Skills.FindByPredicate([&](const FSkillSpec& Spec)
{
return Spec.SkillId == SkillId;
});
}
bool USkillSystemComponent::HasSkill(FName SkillId) const
{
return FindSkillSpec(SkillId) != nullptr;
}
float USkillSystemComponent::GetRemainingCooldown(FName SkillId) const
{
const UWorld* World = GetWorld();
if (!World)
{
return 0.0f;
}
const float* EndTimePtr = CooldownEndTimes.Find(SkillId);
if (!EndTimePtr)
{
return 0.0f;
}
return FMath::Max(0.0f, *EndTimePtr - World->GetTimeSeconds());
}
bool USkillSystemComponent::CanCastSkill(FName SkillId, FString& OutReason) const
{
const FSkillSpec* Skill = FindSkillSpec(SkillId);
if (!Skill)
{
OutReason = TEXT("Skill not found");
return false;
}
if (!Skill->bUnlocked)
{
OutReason = TEXT("Skill is locked");
return false;
}
if (CurrentMana < Skill->ManaCost)
{
OutReason = TEXT("Not enough mana");
return false;
}
const float RemainingCooldown = GetRemainingCooldown(SkillId);
if (RemainingCooldown > 0.0f)
{
OutReason = FString::Printf(TEXT("Skill is cooling down: %.2f s"), RemainingCooldown);
return false;
}
OutReason = TEXT("");
return true;
}
bool USkillSystemComponent::TryCastSkill(FName SkillId)
{
FString FailReason;
if (!CanCastSkill(SkillId, FailReason))
{
OnSkillFailed.Broadcast(SkillId, FailReason);
BP_OnSkillFailed(SkillId, FailReason);
return false;
}
const FSkillSpec* Skill = FindSkillSpec(SkillId);
if (!Skill)
{
const FString Reason = TEXT("Skill not found");
OnSkillFailed.Broadcast(SkillId, Reason);
BP_OnSkillFailed(SkillId, Reason);
return false;
}
CurrentMana = FMath::Clamp(CurrentMana - Skill->ManaCost, 0.0f, MaxMana);
if (UWorld* World = GetWorld())
{
CooldownEndTimes.FindOrAdd(SkillId) = World->GetTimeSeconds() + Skill->Cooldown;
}
OnManaChanged.Broadcast(CurrentMana);
OnSkillCast.Broadcast(SkillId);
// 把真正效果交给蓝图
BP_ExecuteSkill(SkillId);
return true;
}
void USkillSystemComponent::RestoreMana(float Amount)
{
if (Amount <= 0.0f)
{
return;
}
CurrentMana = FMath::Clamp(CurrentMana + Amount, 0.0f, MaxMana);
OnManaChanged.Broadcast(CurrentMana);
}
void USkillSystemComponent::SetMana(float NewMana)
{
CurrentMana = FMath::Clamp(NewMana, 0.0f, MaxMana);
OnManaChanged.Broadcast(CurrentMana);
}
void USkillSystemComponent::AddOrUpdateSkill(const FSkillSpec& NewSkill)
{
if (NewSkill.SkillId.IsNone())
{
return;
}
if (FSkillSpec* Existing = Skills.FindByPredicate([&](const FSkillSpec& Spec)
{
return Spec.SkillId == NewSkill.SkillId;
}))
{
*Existing = NewSkill;
return;
}
Skills.Add(NewSkill);
}
4) 在 Blueprint 里怎么接
这套系统依赖 UE 官方那套 把 C++ 暴露给 Blueprint 的方式:
BlueprintSpawnableComponent 让组件可被蓝图添加;BlueprintCallable 让函数变成蓝图节点;BlueprintImplementableEvent 让你在蓝图里补上最终逻辑。(Epic Games Developers)
按这个流程接:
A. 给角色加组件
在你的 BP_PlayerCharacter 或 BP_EnemyCharacter 里:
- Add Component
- 选择
SkillSystemComponent
B. 配技能表
在组件 Details 面板里给 Skills 填几个条目,例如:
Fireball,ManaCost = 20,Cooldown = 3Dash,ManaCost = 15,Cooldown = 1.5Heal,ManaCost = 25,Cooldown = 5
C. 在蓝图里实现技能表现
在角色蓝图 Event Graph 里实现 BP Execute Skill:
text
Event BP Execute Skill
-> Switch on Name (SkillId)
Fireball:
播放施法动画
SpawnActor(BP_FireballProjectile)
Dash:
Launch Character
Heal:
给自己回血
播放治疗特效
D. 从输入或 UI 调用
例如按键 Q:
text
InputAction Q
-> Try Cast Skill("Fireball")
按键 Space:
text
InputAction Space
-> Try Cast Skill("Dash")
E. UI 接蓝量/冷却
- 蓝量条绑定
GetCurrentMana / GetMaxMana - 技能按钮灰掉时调用
GetRemainingCooldown - 也可以直接监听
OnManaChanged和OnSkillCast
5) 这个版本已经有的能力
这套最小版已经支持:
- 技能列表配置
- 蓝耗检查
- 冷却检查
- 解锁/锁定
- Blueprint 调用技能
- Blueprint 实现技能效果
- UI 事件广播
也就是说,系统层 已经在 C++,表现层完全交给 Blueprint。
6) 一个典型用法
火球技能
在 BP Execute Skill 的 Fireball 分支里:
- 获取角色前方向
SpawnActor(BP_FireballProjectile)- 给投射物传伤害值、速度、施法者
冲刺技能
在 Dash 分支里:
- 读取角色 Forward Vector
LaunchCharacter(ForwardVector * DashStrength, true, true)
治疗技能
在 Heal 分支里:
- 调角色身上的 HealthComponent
Heal(Amount)- 播治疗特效和音效
7) 后面最值得加的 4 个扩展
扩展 1:让技能带更多配置
把 FSkillSpec 扩展成:
cpp
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float Damage = 30.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TSubclassOf<AActor> SkillActorClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<UAnimMontage> CastMontage;
这样 Blueprint 实现时可以直接读配置,不用写死。
扩展 2:加公共查询接口
比如:
cpp
UFUNCTION(BlueprintPure, Category="Skill")
bool IsSkillReady(FName SkillId) const;
给 UI 判断按钮是否可点击。
扩展 3:加 SkillId -> Enum / GameplayTag
当前 FName 足够轻量,但项目大了以后更建议:
- 小项目:
UENUM - 中大项目:
GameplayTag
扩展 4:加多人同步
当前版本是本地逻辑版。
如果要多人,需要把施法入口改成 Server RPC ,再复制蓝量、冷却和技能结果。UE 的 Actor/组件体系支持属性和函数复制,但这一步要单独补网络层。(Epic Games Developers)
8) 什么时候该换 GAS
当你开始需要这些能力时,就该考虑 GAS 了:
- Buff / Debuff
- 属性集(血、蓝、体力、护甲、暴击)
- 效果叠层
- 技能标签互斥/打断
- 复杂冷却和消耗
- 多人预测
- 大量技能的统一框架
这些正是 UE 官方给 GAS 设计的典型场景。(Epic Games Developers)
9) 最后给你一句落地建议
先用这套轻量版把角色技能跑起来,速度最快。
等你把 输入 → 校验 → 冷却 → 蓝耗 → 技能表现 这条链路跑通,再决定要不要升级到 GAS。
下一步我可以直接把它继续补成一套 "火球 + 冲刺 + 治疗"完整示例,包括角色蓝图怎么接、投射物类怎么写、UI 冷却怎么显示。