做 RPG 的人早晚会撞上同一堵墙:角色等级涨了,血量上限和蓝量上限得跟着涨,而且不是随便涨——1 级 100 血、10 级 550 血、50 级小两万,这种曲线你得能画、能改、能让人一眼看懂。在 UE5 的 GAS 体系里,干这件事最顺手也最“正统”的工具就是 MMC(Modifier Magnitude Calculation,修改器幅度计算)。这篇是系列里的第 21 篇上半部分,我打算把血量和蓝量怎么根据等级算出来这件事从头拆到尾:属性怎么放、捕获怎么配、曲线表怎么搭、C++ 子类怎么写、GameplayEffect 怎么挂、初始化顺序怎么排,最后再把我在项目里踩过的那几个坑原样倒给你。读者对象不用太窄——刚接触 GAS 能照着抄,用了一段时间但对 MMC 只有模糊印象的,也能在这篇里把 Snapshot、Capture Source、Aggregator 这几个概念彻底捋顺。
1. 先把问题定义清楚:血量和蓝量凭什么跟着等级走
1.1 硬编码数值的三条死路
先说反例。很多人在 AttributeSet 的构造函数里写InitHealth(100.f),然后在升级的地方写SetHealth(GetMaxHealth() + 50.f)。这条路走三步就堵死。第一步,数值全散在代码里,策划想调个曲线得找你改代码重新编译;第二步,等级回退、转生、装备加成这些情况你没法反向算,因为你的逻辑是“加”而不是“算”;第三步,存档读档后你要重算一遍,一算就容易和运行时数值对不上。
我在早期项目里干过更蠢的事:用一个TMap<int32, FVector2D>把等级对应的血蓝写死在 C++ 里,当时觉得挺整齐,后来策划要加个“难度系数 0.8 倍”,我硬是改了三处代码。从那以后我就认死理——成长数值必须外置到资产里,代码只负责“查表 + 求值”这一件事。
MMC 就是干这个的。它把“我要多少血量”这个问题从 GameplayEffect 的静态配置里抽出来,变成一个可以被覆写的计算函数,每次 Effect 执行时由引擎回调你的代码,你在里面爱怎么算怎么算。
1.2 MMC 在 GAS 的数据流里站在哪一环
要理解 MMC 的位置,得先记住 GAS 应用一次 GameplayEffect 的粗略流程:Effect 被 Apply 之后,引擎遍历它身上挂的每一个 Modifier,为每个 Modifier 求出一个 Magnitude(幅度值),再把这个值按 Modifier Op(Add / Multiply / Divide / Override)合入到目标属性的 Aggregator 里,最后由 Aggregator 算出最终值并写回 AttributeSet。
Magnitude 的来源有好几种,写死的常量、SetByCaller、Attribute Based、Custom Calculation Class。MMC 就是最后这一种的载体——它是一个UGameplayModMagnitudeCalculation的子类,引擎在需要求值时调用它的CalculateBaseMagnitude_Implementation,你返回一个 float,引擎拿这个 float 当 Magnitude 用。
这里有个容易被忽略的点:MMC 算的是“幅度”,不是“最终值”。这个幅度还要经过 Modifier Op 的运算才落到属性上。所以如果你配的是 Add,返回 550 就是加 550;如果你配的是 Override,返回 550 就是直接覆盖成 550。这个区别在初始化血量的时候非常要命,后面 5.2 节会专门讲。
还有一点,一个 MMC 实例每次只服务一个 Modifier。你想同时算血量和蓝量,要么写两个 MMC 类,要么写一个类但用可配置的曲线行名区分,靠创建两个蓝图子类来分别指定。我推荐后者,因为逻辑完全一样,没必要复制代码。
1.3 三种实现路线的横向对比
除了 MMC,还有两条常见路线:ExecCalc(GameplayEffectExecutionCalculation)和“在外部代码里直接 Set 属性”。我把它们放在一起对比一下,你就知道什么场景该选谁。
| 对比维度 | MMC | ExecCalc | 外部直接 SetAttribute |
|---|---|---|---|
| 作用范围 | 单个 Modifier 的幅度 | 一次 Effect 内可同时读写多个属性 | 任意时机,但脱离 GAS 流程 |
| 能否读其他属性 | 能,通过属性捕获 | 能,捕获列表更灵活 | 能,直接读 |
| 能否修改其他属性 | 不能 | 能,输出 Modifier 列表 | 能 |
| 配置复杂度 | 低,GE 里挂一个类引用即可 | 中,需要写出入参和输出列表 | 无配置,但代码耦合高 |
| 网络同步 | 跟着 GE 走,服务端算 | 跟着 GE 走 | 需要自己处理同步 |
| 典型场景 | 按等级/属性算一个值 | 伤害公式、多属性联动结算 | 调试、原型 |
结论很清楚:血量和蓝量的上限计算,只影响一个属性,用 MMC 是最轻的方案。ExecCalc 更适合伤害结算那种“扣血 + 加怒气 + 触发护盾吸收”的复合逻辑。至于第三条路,除非你在做原型,否则别碰,它会让你的属性数值变成“谁最后写谁赢”的混乱局面。
2. MMC 的底层机制:捕获、聚合、求值
2.1 Magnitude 的四种来源
在 GE 编辑器的 Modifier 条目里,Magnitude Calculation Type下拉框有四个选项,很多人第一次看到会懵。我把它们的语义列清楚:
- Scalable Float:给一个基础值和一个曲线表引用,引擎按 Effect 的 Level 在曲线上取系数,再乘基础值。适合“技能等级决定伤害”这种线性成长。
- Attribute Based:从某个属性上取值,可以配系数和前后加值。适合“攻击力的 1.5 倍”这种直接引用。
- SetByCaller:由施加 Effect的一方在 Spec 里塞一个 Tag 对应的数值。适合“这次攻击暴击倍率 2.3”这种运行时才知道的值。
- Custom Calculation Class:挂一个 MMC 类,它的
CalculateBaseMagnitude返回值就是幅度。自由度最高,能写任意逻辑。
Scalable Float 其实也能做等级成长,那为什么还要 MMC?因为 Scalable Float 只认 Effect 的 Level,而 Effect 的 Level 在很多项目里和角色等级不是一回事。你可以自己在 Apply 的时候把 Effect Level 设成角色等级,但一旦项目里 GE Level 被别的地方复用(比如技能等级),语义就混了。我见过一个项目,同一个 GE 既被当技能用又被当属性初始化用,Level 一会儿是 1 一会儿是 30,最后调试到崩溃。用 MMC 把“读哪个属性”这件事显式写出来,比依赖隐式的 Level 靠谱得多。
2.2 属性捕获的定义与 Source/Target 语义
MMC 读其他属性不是直接GetAttributeSet()完事,它走的是**属性捕获(Attribute Capture)**机制。这是 GAS 为了处理网络和预测做的设计:Effect 在应用时把你关心的属性值“扣”出来,存进一个快照容器,MMC 从这个容器里读。
捕获定义是FGameplayEffectAttributeCaptureDefinition,三个参数:
FGameplayEffectAttributeCaptureDefinition( UMyAttributeSet::GetLevelAttribute(), // 捕获哪个属性 EGameplayEffectAttributeCaptureSource::Source, // 从谁身上捕获 false // 是否 Snapshot );Source 和 Target 的语义是新手最容易搞反的地方。Source 指“施加 Effect 的那一方”,Target 指“承受 Effect 的那一方”。给玩家升级加血量,GE 一般是玩家自己 Apply 给自己,或者由 GameMode 施加给玩家——两种情况语义不同。
- 如果是玩家 ASC 对自己
ApplyGameplayEffectToSelf,Source 和 Target 都是玩家自己的 ASC,选哪个都一样。 - 如果是升级系统通过某个管理器 ASC 施加给玩家,Source 是管理器,Target 才是玩家。这时候你读等级必须选Target,选 Source 会读到管理器的等级(通常是 0)。
我建议的做法是:永远想清楚“等级存在谁身上”。等级存在角色自己的 AttributeSet 里,就哪个都能读;等级存在 PlayerState 上,也要确认 PlayerState 的 ASC 和角色的 ASC 是不是同一个(Owned Tags 和 ASC 复用这块是另一个话题)。为了减少歧义,我在项目里统一规定:属性初始化类 GE 全部由角色自己的 ASC 施加给自己,Source 和 Target 都指向自己,捕获时统一用Target,因为 Target 语义上就是“属性被改的那个人”,最不容易搞错。
2.3 Snapshot 到底快照了什么
第三个参数bSnapshot我给它的定义是:这个属性值在 Effect 应用的那一瞬间定格,之后不管怎么变都不影响本次计算。
Snapshot = true:Effect 应用时把当前值拷一份存起来,后续每次求值都用这份拷贝。适合“释放技能瞬间锁定攻击力”的场景——你中途换装备不该影响已经在飞的这发火球。Snapshot = false:每次求值都重新从 ASC 里取最新值。适合“持续型 Effect 需要跟随属性变化”的场景。
血量和蓝量初始化这里,两种都能跑通,因为初始化 Effect 是 Instant 的,应用完就结束,只有一次求值。但我强烈建议用 false,理由有二。
其一,等级是个会变的属性。如果你的初始化 GE 某天被改成Has Duration(比如你想加个升级动画期间的血量缓慢增长效果),Snapshot 为 true 会导致整个持续期间都按 Apply 那一刻的等级算,升级升了也白升。其二,Snapshot 为 true 时如果捕获时机早于属性初始化完毕,你会捕获到一个 0,而且这个 0 会一直用下去,排查起来非常痛苦。Non-Snapshot 至少会在下次求值时自愈。
提示:Snapshot 不是性能优化开关。它的真正作用是“时间点锁定”,别为了省一次取值就乱开。
2.4 AggregatorEvaluateParameters 在干嘛
CalculateBaseMagnitude_Implementation的签名里有个const FGameplayEffectSpec& Spec,你要拿捕获值必须构造一个FAggregatorEvaluateParameters:
FAggregatorEvaluateParameters EvalParams; EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags(); EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();这两个 Tag 容器的用途是:当一个属性被多个 Modifier 影响时(比如基础等级 + 装备加成 + Buff),Aggregator 需要判断哪些 Modifier 该参与运算。GAS 允许 Modifier 配置“有某 Tag 时才生效”和“忽略某 Tag 的 Modifier”,EvalParams 就是把这些 Tag 传进去做过滤的。
绝大多数情况下你直接照抄上面两行就行。但如果你项目里用过FAggregatorModChannel的SourceTagFilter,就得注意:如果 EvalParams 传空,被 Tag 条件限制住的 Modifier 可能被意外算进去或者漏掉,导致你读到的等级比实际高或低。这个坑我在一个 Buff 系统里遇到过,表现是“角色身上有某个 Debuff 时血量算出来偏低 20%”,查了两天才定位到是 EvalParams 没传 Tag。
3. 数据侧准备:等级属性与成长曲线
3.1 把 Level 放进 AttributeSet
等级本身是不是属性?严格说它更像“状态”而不是“属性”,但在 GAS 里把它做成 Attribute 是最省事的,因为这样它能被捕获、能被 GE 修改、能自动同步。
我在 AttributeSet 里加这么一段:
// MyRPGAttributeSet.h UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Level, Category = "Meta Attributes") FGameplayAttributeData Level; ATTRIBUTE_ACCESSORS(UMyRPGAttributeSet, Level)ATTRIBUTE_ACCESSORS这个宏会生成 Getter、Setter、Init 函数和一个静态的GetLevelAttribute(),后者正是捕获定义需要的那个函数指针。
初始值在构造函数里给:
UMyRPGAttributeSet::UMyRPGAttributeSet() { InitLevel(1.f); InitHealth(0.f); InitMaxHealth(0.f); InitMana(0.f); InitMaxMana(0.f); }注意我把 Health 初始化成 0 而不是 100。原因在 5.2 节,先记住这个细节。
等级放在 AttributeSet 里还有一个附带好处:PreAttributeChange和PostGameplayEffectExecute里能统一拦截,比如你想在等级变化时触发一次重新计算,在这里加逻辑就行。但要注意,Attribute 的 Clamp 逻辑不要写在PreAttributeChange里去做“重算 MaxHealth”这种副作用,那个函数会被调用很多次,容易出循环。
3.2 曲线表 vs 数据表:两种查表方案
数值外置有两个主流载体,我列个表对比:
| 维度 | CurveTable(曲线表) | DataTable(数据表) |
|---|---|---|
| 数据结构 | 每行一条浮点曲线,可多个 Key | 每行一个自定义结构体 |
| 插值能力 | 自带线性/三次插值,等级之间平滑过渡 | 只能离散查表,中间等级要么补行要么自己插值 |
| 编辑器体验 | 有可视化曲线图,调数值直观 | 表格形式,适合多字段 |
| 运行时取值 | FindCurve()->Eval(Level)一行搞定 | 需要FindRow再做字段读取 |
| 适合场景 | 单一数值随等级连续变化 | 一等级对应一整套配置(血量、蓝量、攻击、防御) |
血量和蓝量都是“随等级连续变化”的单一数值,曲线表更合适,因为策划可以在编辑器里直接拖曲线,插值也白送。如果哪天你要做成“每个等级配一整张表”,再换 DataTable 不迟。
顺便说一句,UE5 里曲线表用的是UCurveTable,行里存的是FRealCurve,取值方法Eval(float InTime)返回 float。时间轴就是你的等级,X 轴 1 到 100,Y 轴是对应数值,非常简单。
3.3 曲线表的具体搭法
步骤我按顺序写,照着做就行。
- 内容浏览器右键 → Miscellaneous → Curve Table,命名比如
CT_CharacterGrowth。 - 双击打开,在行列表里点
+新增一行,行名填Health。 - 点进这一行,会看到一条空曲线,在图表区右键 Add Key,先加两个关键点:(1, 100) 和 (10, 550)。
- 再新增一行,行名
Mana,关键点 (1, 50) 和 (10, 300)。 - 选中曲线,把 Interp Mode 设成
Cubic或Linear。Cubic 更平滑,但要注意它可能过冲,在等级 1 附近产生低于 100 的值,所以第一个关键点的前一段建议用 Linear,或者在代码里做一次FMath::Max兜底。
我实际配的时候会在 1 到 100 级之间铺 10 到 15 个关键点,而不是只放两个端点。原因是纯线性成长在 RPG 里手感很差——前期涨太快、后期涨太慢。放几个中间点,比如 (20, 1200)、(40, 3800)、(60, 8000)、(80, 14000),曲线形状更可控,策划调起来也有抓手。
注意:曲线表的行名是 FName,代码里引用时大小写敏感,最好定义成常量或者用
FName(TEXT("Health"))统一,别一处写Health一处写health,这种错查起来要命。
还有个小技巧:同一条曲线可以给多个 MMC 实例使用。你不需要为血量建一张表、为蓝量建一张表,一张表两行就够了,MMC 里暴露一个FName CurveRowName属性,创建两个蓝图子类分别填Health和Mana,共用同一份资产。后期策划改数值只改一处,清爽。
4. 手写 MMC 子类:从构造函数到求值函数
4.1 头文件与类声明
MMC 的基类是UGameplayModMagnitudeCalculation,头文件在GameplayEffectMagnitudeCalculation.h(通常在 GAS 插件的公共头里,项目里一般通过AbilitySystemGlobals相关模块引用)。
// MMC_AttributeByLevel.h #pragma once #include "CoreMinimal.h" #include "GameplayModMagnitudeCalculation.h" #include "MMC_AttributeByLevel.generated.h" class UCurveTable; UCLASS() class MYRPG_API UMMC_AttributeByLevel : public UGameplayModMagnitudeCalculation { GENERATED_BODY() public: UMMC_AttributeByLevel(); virtual float CalculateBaseMagnitude_Implementation( const FGameplayEffectSpec& Spec) const override; protected: // 捕获定义,必须在构造函数里 Add 到 RelevantAttributesToCapture FGameplayEffectAttributeCaptureDefinition LevelCaptureDef; // 成长曲线表 UPROPERTY(EditDefaultsOnly, Category = "Growth") TObjectPtr<UCurveTable> GrowthCurveTable; // 用哪一行 UPROPERTY(EditDefaultsOnly, Category = "Growth") FName CurveRowName = TEXT("Health"); // 等级上限兜底,防止曲线外插值 UPROPERTY(EditDefaultsOnly, Category = "Growth") float MaxSupportedLevel = 100.f; };三个 UPROPERTY 是给蓝图子类配置用的,EditDefaultsOnly表示只能在类默认值里改,实例上改不了——这对 MMC 是对的,因为它本来就是个无状态的计算器,不需要实例化配置。
4.2 构造函数里必须做的事
构造函数只干一件事:把要捕获的属性登记进RelevantAttributesToCapture。
UMMC_AttributeByLevel::UMMC_AttributeByLevel() { LevelCaptureDef = FGameplayEffectAttributeCaptureDefinition( UMyRPGAttributeSet::GetLevelAttribute(), EGameplayEffectAttributeCaptureSource::Target, false); RelevantAttributesToCapture.Add(LevelCaptureDef); }这里有三个必须踩准的点。
第一,捕获定义必须登记到RelevantAttributesToCapture。这个数组是 UPROPERTY,引擎在 GE 应用时会遍历它去扣值。你只在成员变量里存了定义但没 Add,GetCapturedAttributeMagnitude会返回 false,你就拿到 0。
第二,登记动作必须在构造函数里完成,不能放到BeginPlay或者Calculate里。因为RelevantAttributesToCapture是序列化的,引擎在 CDO 阶段就读取它来决定捕获什么。你在运行时往里面塞,捕获容器早就建好了,不会重新捕获。
第三,Source和Target的选择要在项目里统一定死。我上面选了 Target,如果你的初始化 GE 是由别的 ASC 施加的,且等级在 Target 身上,那没问题;如果等级其实在 Source 身上,改成 Source。这件事没有“通用正确答案”,只有“你项目里一致就行”。
4.3 CalculateBaseMagnitude_Implementation 逐行拆
这是核心函数,我按执行顺序拆开讲。
float UMMC_AttributeByLevel::CalculateBaseMagnitude_Implementation( const FGameplayEffectSpec& Spec) const { // 1. 准备求值参数 FAggregatorEvaluateParameters EvalParams; EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags(); EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags(); // 2. 读取等级 float Level = 0.f; const bool bGotLevel = GetCapturedAttributeMagnitude( LevelCaptureDef, Spec, EvalParams, Level); if (!bGotLevel) { UE_LOG(LogTemp, Warning, TEXT("[MMC_AttributeByLevel] 捕获 Level 失败,Spec: %s"), *Spec.ToDebugString()); return 0.f; } // 3. 做一次夹取,防止曲线外插值出负数或异常值 Level = FMath::Clamp(Level, 1.f, MaxSupportedLevel); // 4. 查曲线 if (!GrowthCurveTable || CurveRowName.IsNone()) { UE_LOG(LogTemp, Warning, TEXT("[MMC_AttributeByLevel] 曲线表或行名未配置")); return 0.f; } static const FString ContextString(TEXT("MMC_AttributeByLevel")); const FRealCurve* Curve = GrowthCurveTable->FindCurve(CurveRowName, ContextString); if (!Curve) { UE_LOG(LogTemp, Warning, TEXT("[MMC_AttributeByLevel] 曲线表里找不到行 %s"), *CurveRowName.ToString()); return 0.f; } // 5. 求值并兜底 const float Result = Curve->Eval(Level); return FMath::Max(Result, 1.f); }逐点解释。
第 2 步的GetCapturedAttributeMagnitude有四个参数:捕获定义、Spec、求值参数、输出值。注意它返回的是“这个属性的当前聚合值”,也就是已经把所有 Modifier 算进去的值。如果等级被 Buff 临时加了 2 级,你读到的就是加完的值,这通常正是你想要的。
第 3 步的 Clamp 别省。曲线表在关键点之外做外插值,等级 0 的时候可能算出一个负数,等级 150 的时候可能算出天文数字。夹一下成本极低,能省掉一大堆莫名其妙的数值问题。
第 4 步的static const FString ContextString是FindCurve的出参,用于在找不到行时汇报是哪个上下文调用的。用 static 是为了避免每次求值都构造一个 FString,虽然开销不大,但 MMC 会被频繁调用,能省则省。
第 5 步的FMath::Max(Result, 1.f)是保险丝:万一曲线被配成 0 或者负数(我见过策划误删关键点导致 -1),至少保证属性上限不是 0 或负数,否则角色一初始化就直接死。
提示:日志别用
UE_LOG(LogTemp, ...)一条道走到黑。正式项目里给 GAS 相关模块建一个自己的 Log Category,用 Verbose 级别打详细日志,出问题时临时调高日志级别,平时不刷屏。
4.4 蓝图子类还是 C++ 子类
MMC 支持纯蓝图实现(新建 Blueprint Class,父类选 GameplayModMagnitudeCalculation),在蓝图里重写Calculate Base Magnitude节点,用Get Captured Attribute Magnitude拿到值再查表。
什么时候用蓝图?逻辑简单、需要策划频繁调整、不想每次改动都编译 C++ 的时候。什么时候用 C++?需要访问引擎底层接口、需要复杂数学、需要性能敏感的时候。
我的做法是混合:C++ 写一个通用基类,把捕获、Clamp、查表、日志全写好,暴露CurveRowName和GrowthCurveTable两个属性;然后在蓝图里建两个子类,一个填Health一个填Mana。这样代码只有一份,配置在蓝图里,策划改曲线不用找我,我改逻辑也不用策划重新连蓝图。
顺带说个好处:蓝图子类的类引用可以直接在 GE 编辑器的Modifier Magnitude Calculation下拉里选中,不需要额外写 C++ 中转。这也是我推荐蓝图子类的实际原因——GE 的 Modifier 配置项接受的是类引用,蓝图类是类,C++ 类也是类,但蓝图类在编辑器里对非程序员更友好。
5. 挂进 GameplayEffect 并跑通初始化
5.1 GE 的 Modifier 配置步骤
先创建一个 GameplayEffect 蓝图,命名GE_InitAttributes。然后按顺序配:
Duration Policy设为Instant。血量和蓝量的初始化是一次性的,不需要持续时间,Instant 的 Effect 不会进入 Active GameplayEffects 容器,开销最小。- Modifiers 里点
+添加一条。Attribute选MyRPGAttributeSet.MaxHealth。 Modifier Op选Add。Magnitude Calculation Type选Custom Calculation Class。Modifier Magnitude Calculation里选你建好的蓝图子类BP_MMC_MaxHealthByLevel。- 再添加一条 Modifier,
Attribute选MaxMana,同样挂BP_MMC_MaxManaByLevel。
配完最大化血量上限之后,血量本身怎么填满?两种做法。
做法 A(推荐):再建一个 GE,GE_FillVitals,两条 Modifier 分别对Health和Mana做Add,幅度类型选Attribute Based,Attribute分别指向MaxHealth和MaxMana,系数 1.0。这样血量加上“最大值”就等于满血。这个 GE 是通用工具,以后复活、吃药回满都能用。
做法 B:在同一个GE_InitAttributes里再加两条 Modifier,对Health和Mana用 Attribute Based 指向MaxHealth/MaxMana。赌的是引擎按顺序执行 Modifier 列表,先算上限再填血。这个赌注很危险,因为 Instant Effect 的 Modifier 执行顺序虽然在实践中通常是列表顺序,但没有明确的契约保证,而且当两条 Modifier 指向同一属性时,聚合顺序更难预测。我建议用做法 A,两个 GE 分两次 Apply,顺序明确。
5.2 初始化顺序:Base Value、MaxHealth、Health 的先后关系
这是整篇里最容易出错的地方,我单独拿出来讲。
Instant + Add 的 Effect 作用在属性上时,改的是Base Value,不是 Current Value。而GetHealth()返回的是 Base Value 加上所有 Active Modifier 的聚合结果。初始化时没有 Modifier,所以 Current = Base。
那么问题来了:如果 AttributeSet 构造函数里InitHealth(100.f),然后你 Apply 一个 Add 550 的 Effect,结果是多少?650。因为 Add 是加法,不改写。
这就是为什么我在 3.1 节里把 Health 初始化成 0。初始值给 0,Apply 一次 Attribute Based 的“填满”Effect,结果正好等于 MaxHealth,干净利落。
完整的初始化时序应该是这样:
void AMyRPGCharacter::InitializeAttributes() { if (!AbilitySystemComponent || !InitAttributesEffect) return; // 1. 先算上限 FGameplayEffectContextHandle Context = AbilitySystemComponent->MakeEffectContext(); Context.AddSourceObject(this); FGameplayEffectSpecHandle SpecHandle = AbilitySystemComponent->MakeOutgoingSpec( InitAttributesEffect, 1.f, Context); if (SpecHandle.IsValid()) { AbilitySystemComponent->ApplyGameplayEffectSpecToSelf( *SpecHandle.Data.Get()); } // 2. 再填满 if (FillVitalsEffect) { FGameplayEffectSpecHandle FillHandle = AbilitySystemComponent->MakeOutgoingSpec( FillVitalsEffect, 1.f, Context); if (FillHandle.IsValid()) { AbilitySystemComponent->ApplyGameplayEffectSpecToSelf( *FillHandle.Data.Get()); } } }注意SpecHandle的 Level 参数我传了 1.f。因为我们读的是 AttributeSet 里的 Level 属性,不是 Spec Level,所以这里传什么无所谓。但如果你哪天偷懒改成读Spec.GetLevel(),这个 1.f 就会变成所有人的等级,这种 bug 很隐蔽。
还要注意调用时机。InitializeAttributes不能放在 Character 的BeginPlay里裸调,因为那时 ASC 可能还没初始化完(PlayerState 的复制还没到)。常规做法是通过AbilitySystemComponent->AddLooseGameplayTag或者监听OnRep_PlayerState,等 ASC 就绪后再初始化。如果项目里用了InitAbilityActorInfo,就在那之后紧接着调。
5.3 一个 GE 管血蓝两件事的配法
有人会问:能不能一个 GE 搞定,少 Apply 一次?
能。GE_InitAttributes里配四条 Modifier:MaxHealth、MaxMana、Health、Mana。前两条挂 MMC 算上限,后两条用 Attribute Based 指向刚才的上限。风险我上面说了——顺序不可控。
但我后来发现一个更稳的写法:后两条不用 Attribute Based,也用 MMC,捕获 MaxHealth 和 MaxMana。虽然写起来多一个类,但好处是每条 Modifier 的计算是独立的,不依赖聚合顺序。不过这条路的代价是你需要三个 MMC(MaxHealth、MaxMana、Health=Mana 共用逻辑但捕获的源属性不同,可以合并成一个带FGameplayAttribute配置项的类)。
老实说,为了省一次 Apply 调用而去重构这套东西,收益很低。Instant Effect 的开销是微秒级的,一局游戏里初始化也就那么一两次。我自己的项目就是两个 GE 分两次 Apply,三年没出过问题。工程上,清晰比“优雅”重要。
6. 踩坑记录与排查速查
6.1 拿到 0 或者拿到脏数据
症状:血量上限永远是 0,或者永远是一个固定值不随等级变。
排查顺序我整理成这样:
| 现象 | 可能原因 | 验证方法 | 处理 |
|---|---|---|---|
| 捕获返回 false | 捕获定义没 Add 进 RelevantAttributesToCapture | 在构造函数里打断点或加日志 | 补上 Add |
| 捕获到 0 | Source/Target 选反了 | 把另一个方向也打日志对比 | 改成正确方向 |
| 捕获到 0 | 等级属性还没初始化就被捕获 | 检查初始化时序 | 延后 GE 的 Apply 时机 |
| 值固定不变 | bSnapshot 设成了 true | 查看捕获定义第三个参数 | 改成 false |
| 值固定不变 | 读的是 Spec.GetLevel() | 搜代码里的 GetLevel | 改成读属性捕获 |
| 查表返回 0 | 曲线行名大小写不匹配 | 打印 CurveRowName 和表里所有行名 | 统一 FName |
| 查表返回 0 | 曲线表引用为空 | 检查蓝图子类默认值 | 补上引用 |
这张表我贴在项目文档里,新来的同学第一周基本都用得上。
6.2 等级变了血量不跟着变
升级之后血量上限没更新,这是设计层面的问题不是 bug——MMC 只在 Effect 应用时执行,它没有“监听属性变化”的能力。
解决方式有两种。一种是升级时重新 Apply 一次初始化 GE,简单粗暴但有效,注意先 Apply 上限 GE 再 Apply 填满 GE,而且“填满”这一步要小心别把玩家当前的残血状态填回满血(用 Delta 而不是 Set)。另一种是把血量上限做成一个Infinite的持续 GE,用 MMC 算幅度,等级变化时 Remove 再 Add。第二种更优雅,但持续 GE 会进 Active 容器,每次属性聚合都要重算,开销略高。
我选第一种,配一个GE_RecalculateMaxVitals,升级时 Apply,只改上限不动当前值。填血这一步单独处理,比如升级时回满 20%,那是另一个 Effect。
6.3 初始化后血量翻倍
症状:设计上应该是满血 550,实际是 1100。
原因基本就一个:初始值给了非零,同时又 Apply 了 Add 类型的填满 Effect。解法见 5.2,把函数体里的InitHealth(0.f)改成 0。如果项目里已经有一堆地方依赖初始值 100,那就把填满 Effect 改成 Override 类型,或者干脆改成用 SetByCaller 传精确值。
还有一种隐蔽原因:初始化 GE 被 Apply 了两次。常见于BeginPlay和OnRep_PlayerState都调了初始化函数,网络环境下客户端和服务端各跑一次,或者监听重复绑定。加一个bAttributesInitialized布尔守卫,成本极低。
6.4 几个我自己的实操心得
最后说几条文档里不会写的经验。
第一,曲线表的 X 轴不要用整型等级直接当时间轴。如果你的等级会超过 100,或者有负数等级(某些游戏的“堕落值”系统),在 MMC 里先做一次归一化再 Eval,否则外插值会把数字拉到离谱。我一般会把 Eval 的输入夹在[1, MaxSupportedLevel],多写一行 Clamp,省十天调试。
第二,给 MMC 的返回值加日志开关。别用UE_LOG硬打,用一个UPROPERTY(EditDefaultsOnly) bool bDebugLog控制,编辑器里勾上就打印“等级 X → 血量 Y”,出问题一眼看穿。
第三,蓝图子类的默认值一定要在 Class Defaults 里配,不要在关卡实例上配。MMC 不是 Actor,没有实例概念,任何人都不会去“放置”它,所以在默认值里配好是最稳的。我有次在一个测试地图里配了实例值,换了个地图就失效,查了半天。
第四,MMC 里的代码要保持纯函数风格。不要在里面改角色状态、不要 Apply 其他 Effect、不要写存档。它是被引擎在聚合过程中调用的,调用时机和频率都不由你控制,加副作用会引发极难复现的问题。这是我见过最贵的教训之一——曾经有个同学在 MMC 里写了“如果血量低于 10% 就自动触发狂暴”,结果属性聚合过程中触发了 Effect 应用,形成递归,直接爆栈。
下篇我打算把这条线的另一半补上:怎么用 ExecCalc 处理“升级瞬间同时改血蓝攻防四个属性”,以及 MMC 在客户端预测下的表现和调试工具怎么搭。曲线表这块还有个小坑——多人在同一张表上并行编辑时的资源锁问题,也留到下篇一起说。