GAS 这套框架最不舒服的地方,从来不是写 GameplayAbility,而是角色刚进场景那一瞬间,属性到底从哪儿来、什么时候生效、客户端跟服务器对不对得上。UE5 GAS RPG 初始化角色的属性这件事,踩过坑的人都懂:服务器上血条明明是 200,客户端打开却是 0,或者一进游戏属性翻倍,再或者升级后属性回到解放前。这类问题八成不是 GAS 本身有毛病,而是初始化链路的时序没理顺。
这篇内容面向的读者很明确:已经能把 AttributeSet 挂上、GE 能应用、但属性同步或数值明显不对劲的开发者。我会把整套初始化思路拆开讲,从属性数据该用什么结构装、构造函数的默认值到底该不该背锅,到 PossessedBy 和 OnRep_PlayerState 这两个入口为什么必须成对出现,再往下落到 GE 配置、CurveTable 取值、夹取逻辑的完整代码。每一处都带取舍理由和实测踩过的坑,看完能直接照着搭一套可用可扩的初始化流程。
1. 先想清楚"初始化属性"到底在初始化什么
1.1 一个几乎人人都遇过的现象
想象一个最小可复现的场景:角色类里用构造函数给生命值塞了Health(100.f),本地单机跑得好好的,血条正常。换到多人测试,客户端进去血条是空的,服务器上的日志显示 100。你把 GE 加进去做初始化,情况变成:服务器血量 200,客户端 100。再后来加了等级系统,1 级时一切正常,升到 2 级再重新进场景,属性又变回 1 级的数值。
这三个现象看着不一样,根子其实是同一个:属性有"初始指"和"玩家可见值"两条链路,而这两条链路的生效时机不同。构造函数赋值走的是 CDO 复制这条路,速度快、默认就有,但只在创建实例的那一刻起作用;GE 初始化走的是 GameplayEffect 系统,会触发修饰符计算、复制、回调,链路长但可控可扩展。搞混这两条路,就会出现上面那些症状。
所以我把初始化的目标拆成三个必须同时满足的条件:服务器端每个属性的 BaseValue 是"这个角色在这个等级下应该有的值";客户端在 ASC 的 ActorInfo 就绪后能拿到同一份值;后续任何一条 GE 修改,都在这个基础上做加法或乘法,而不是覆盖掉。三条缺一条,数值就飘。
1.2 初始化的四个硬约束
做这套东西时我给自己列了四条不能破的规矩,后面所有设计都是围绕它们展开的。
第一,初始化只跑在服务器。基础属性属于权威数据,客户端自己算一遍再跟服务器打架,同步抖动会非常难看。客户端的活只有一件:等复制过来。
第二,初始化必须发生在 ASC 的 OwnerActor 与 AvatarActor 都有效之后。这点最容易被忽略,尤其是玩家角色,ASC 通常挂在 PlayerState 上,而 PlayerState 的复制是晚于 Character 的 BeginPlay 的。在 BeginPlay 里初始化,十有八九拿到的是空指针或者一个还没接上 PlayerState 的 ASC。
第三,初始化只能做一次。无缝切图、断线重连、PossessedBy 被重复触发,都会让初始化跑第二遍。加法型 GE 跑两遍就是翻倍,这个 bug 现场非常隐蔽,因为单机测试根本复现不了。
第四,初始化和后续加成必须是可分离的两层。装备、Buff、等级成长都应该是叠加在初始值之上的修饰,而不是重写初始值。一旦初始化和加成混在一层,想做"卸下装备恢复原属性"就得自己记账,越写越乱。
1.3 为什么不用构造函数那一套
有人会问,构造函数直接给FGameplayAttributeData塞初值不就行了,为什么要兜这么大一圈。能行,但有明确的边界:构造函数的值是 CDO 的默认值,所有实例共享同一份初始数据,一旦这个角色需要"1 级 100 血、10 级 550 血",构造函数就表达不了。它适合做一个保底值,防止某个属性在初始化前被读到 0 导致 UI 除零或者逻辑异常,但不适合做真正的数值来源。
我的做法是构造函数里保留一份偏保守的兜底值,真正的数值全部由 GE 在服务器初始化时写入,并且用 Override 类型的修饰符覆盖掉兜底值,这样即使初始化跑了两次,结果也是确定的,不会叠加。这个细节后面在 GE 配置那一节会展开。
2. 属性体系设计:双轨数值与数据驱动
2.1 BaseValue 和 CurrentValue 别再混着用
FGameplayAttributeData内部就是两个 float:BaseValue 和 CurrentValue。很多人第一次看觉得多余,实际这是 GAS 最值钱的设计之一。BaseValue 是"永久值",CurrentValue 是"当前生效值",两者的关系是:CurrentValue 永远等于 BaseValue 经过所有持续型修饰(比如一个 10 秒的减益)之后的即时结果。
举个具体例子。角色基础攻击 100,捡到一把 +20 的剑,这时候 BaseValue 变成 120;再踩到一个 -30% 攻击的减速区域,CurrentValue 就是 84。减速结束时,GAS 会把持续修饰移除,CurrentValue 自动回到 120,你不需要写任何"恢复"代码。如果把这两层揉成一个值,减速结束后你就得自己记着"原本是 120",一旦期间换了装备,账就乱了。
初始化阶段要关心的只有 BaseValue。所有初始化 GE 都应该是对 BaseValue 的修改,CurrentValue 由系统自动派生。理解了这一点,很多"初始化后属性显示不对"的问题就能自己定位了——大概率是你读的是 CurrentValue,但修饰还没算完。
2.2 主属性、次属性、Meta 属性的三层划分
一套 RPG 属性表如果只有一层,写到中期一定失控。我习惯按用途分三层,这个划分直接影响初始化的顺序。
| 层级 | 典型属性 | 特性 | 初始化顺序 |
|---|---|---|---|
| 主属性 | 力量、敏捷、智力、体质 | 只由等级和职业决定,几乎不被临时修改 | 第一层 |
| 次属性 | 攻击力、护甲、暴击率、移速 | 由主属性推导 + 装备加成 | 第二层,依赖主属性 |
| 关键属性 | 生命、法力、耐力 | 需要上限做夹取,变化最频繁 | 第三层,依赖次属性 |
| Meta 属性 | 伤害、治疗量、消耗 | 不参与复制,用完即清零的中转值 | 不进初始化 |
这个顺序不是随便排的。次属性的计算依赖主属性,关键属性的上限(比如 MaxHealth)依赖次属性里的体质,所以必须按依赖方向依次应用。如果你把三层塞进同一张 GE,或者顺序反了,MaxHealth 还没算出来就去夹取 Health,结果只能是 0 或者兜底值。
Meta 属性单独说一句:它不是真实存在的属性,只是一个"传话的"。比如伤害流程是技能算出一个 Damage 值写进 Meta 属性,PostGameplayEffectExecute里监听到 Damage 被写入,再手动扣 Health,然后把 Damage 清零。它不需要初始化,也不该复制,标记成不复制就行。
2.3 用数据表驱动,别把数值写死在代码里
属性数值放哪儿,是个很实际的问题。写在构造函数里改一次要重编译,写在蓝图默认值里版本管理一团糟,我的选择是 CurveTable(曲线表)或 DataTable 二选一。
CurveTable 的好处是天生带等级维度。一列叫 Strength,行是 1 到 60 级,取值时直接Curve->Eval(Level)就能拿到插值结果,升级曲线平滑,策划改数值不用碰程序。DataTable 的好处是能塞更多元数据,比如每级还带一个描述文本、一个图标。属性初始化这个场景我选 CurveTable,因为 GE 的 Modifier 里可以直接指定"Custom Curve Table"类型的量级,配置全在资产里完成,代码一行不用改。
具体配置方式是在 GE 的 Modifier 里,Magnitude Calculation Type 选Scalable Float,然后在 Curve Table 里选表、填行名,Level 会自动作为输入。这条链路打通之后,新增一个属性只要建一列曲线、加一个 Modifier,扩展成本极低。
3. 代码落地:AttributeSet 的声明与回调
3.1 属性声明的一整套写法
先说清楚依赖。AttributeSet 的头文件里要包含AttributeSet.h和AbilitySystemComponent.h,后者是为了拿到那一组访问器宏。很多人第一次写会漏掉宏定义,编译报"找不到 GetStrength",就是这里的问题。
// AttributeSetBase.h #pragma once #include "CoreMinimal.h" #include "AttributeSet.h" #include "AbilitySystemComponent.h" #include "AttributeSetBase.generated.h" #define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName) UCLASS() class RPGDEMO_API UAttributeSetBase : public UAttributeSet { GENERATED_BODY() public: UAttributeSetBase(); UPROPERTY(BlueprintReadOnly, Category = "Attributes|Primary", ReplicatedUsing = OnRep_Strength) FGameplayAttributeData Strength; ATTRIBUTE_ACCESSORS(UAttributeSetBase, Strength) UPROPERTY(BlueprintReadOnly, Category = "Attributes|Vital", ReplicatedUsing = OnRep_Health) FGameplayAttributeData Health; ATTRIBUTE_ACCESSORS(UAttributeSetBase, Health) UPROPERTY(BlueprintReadOnly, Category = "Attributes|Vital", ReplicatedUsing = OnRep_MaxHealth) FGameplayAttributeData MaxHealth; ATTRIBUTE_ACCESSORS(UAttributeSetBase, MaxHealth) UPROPERTY(BlueprintReadOnly, Category = "Attributes|Meta") FGameplayAttributeData Damage; ATTRIBUTE_ACCESSORS(UAttributeSetBase, Damage) virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override; virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override; protected: UFUNCTION() virtual void OnRep_Strength(const FGameplayAttributeData& OldStrength); UFUNCTION() virtual void OnRep_Health(const FGameplayAttributeData& OldHealth); UFUNCTION() virtual void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth); void ClampAttribute(const FGameplayAttribute& Attribute, float& NewValue) const; };那个宏一次生成四个东西:静态的 Attribute 获取器(给 GE 用)、Get 取值、Set 设值、Init 初始化。少了宏就得手写四份,容易漏。ReplicatedUsing必须配一个OnRep_函数,否则客户端改了值之后 UI 不刷新。
3.2 复制注册与回调实现
复制的写法有个固定套路,DOREPLIFETIME_CONDITION_NOTIFY加REPNOTIFY_Always,原因是属性可能在服务器上被设成同一个值,默认的 RepNotify 只在新旧值不同时触发,会漏掉刷新。
void UAttributeSetBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME_CONDITION_NOTIFY(UAttributeSetBase, Strength, COND_None, REPNOTIFY_Always); DOREPLIFETIME_CONDITION_NOTIFY(UAttributeSetBase, Health, COND_None, REPNOTIFY_Always); DOREPLIFETIME_CONDITION_NOTIFY(UAttributeSetBase, MaxHealth, COND_None, REPNOTIFY_Always); } void UAttributeSetBase::OnRep_Health(const FGameplayAttributeData& OldHealth) { GAMEPLAYATTRIBUTE_REPNOTIFY(UAttributeSetBase, Health, OldHealth); }GAMEPLAYATTRIBUTE_REPNOTIFY这个宏内部会做两件事:把旧值记进 ASC 的预测历史里,然后广播属性变化事件。少了它,客户端的 UI 绑定回调不会触发,你会在日志里看到值确实变了,但界面上纹丝不动。
接下来是最容易写出 bug 的两个虚函数。PreAttributeChange在 CurrentValue 即将被修改之前调用,参数是引用,你的任务是把它改到合法范围内;PostGameplayEffectExecute在一次 GE 执行完之后调用,适合做"派生计算"和"清理 Meta 属性"。
void UAttributeSetBase::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { Super::PreAttributeChange(Attribute, NewValue); ClampAttribute(Attribute, NewValue); } void UAttributeSetBase::ClampAttribute(const FGameplayAttribute& Attribute, float& NewValue) const { if (Attribute == GetHealthAttribute()) { NewValue = FMath::Clamp(NewValue, 0.f, GetMaxHealth()); } else if (Attribute == GetMaxHealthAttribute()) { NewValue = FMath::Max(NewValue, 1.f); } }注意:
PreAttributeChange里绝对不能调SetHealth()。那个函数会再次触发 PreAttributeChange,形成无限递归,编辑器直接卡死。要改只改传进来的NewValue引用。
PostGameplayEffectExecute负责处理 Meta 属性那条链路:
void UAttributeSetBase::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) { Super::PostGameplayEffectExecute(Data); if (Data.EvaluatedData.Attribute == GetDamageAttribute()) { const float LocalDamage = GetDamage(); SetDamage(0.f); // 用完立刻清零 const float NewHealth = FMath::Clamp(GetHealth() - LocalDamage, 0.f, GetMaxHealth()); SetHealth(NewHealth); } else if (Data.EvaluatedData.Attribute == GetHealthAttribute()) { SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth())); } else if (Data.EvaluatedData.Attribute == GetMaxHealthAttribute()) { // 上限变化时把当前值也夹一次,防止上限降了血条超出 SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth())); } }第三个分支是我吃过亏才加的。假设角色当前 200 血、上限 200,卸下一件 +100 生命的装备,上限掉到 100,如果不在上限变化时重新夹一次,血条就会显示 200/100,UI 直接画爆。
3.3 属性注册进 ASC 的两种路径
AttributeSet 必须被 ASC 登记到SpawnedAttributes里,否则GetSet<UAttributeSetBase>()返回空,GE 找不到目标属性,全部静默失败。有两条路。
一条是在 Character 构造函数里CreateDefaultSubobject<UAttributeSetBase>(TEXT("AttributeSet")),引擎在 ASC 组件初始化阶段会扫描 Owner 上的 AttributeSet 组件并自动登记。这条最省事,适合"一个角色一套属性"的常规情况。
另一条是运行时动态创建,调用ASC->GetOrCreateAttributeSubobject<UAttributeSetBase>(),适合属性集需要按职业或者按配置动态组合的场景。动态创建的 AttributeSet 必须在服务器上创建,客户端靠复制拿到,两边都得拿到同一个实例。
不管走哪条,初始化之后我都会加一句防御性检查:
if (!ensureAlwaysMsgf(GetAbilitySystemComponent()->GetSet<UAttributeSetBase>(), TEXT("AttributeSet 没有注册到 ASC,检查创建路径"))) { return; }这句ensure在很多次调试里救过我,因为它把"属性全 0"这个模糊现象直接定位到了注册这一步,省掉半小时的排查。
4. 初始化链路打通:ASC、ActorInfo 与 GE 时机
4.1 玩家角色:PossessedBy 与 OnRep_PlayerState 成对出现
玩家角色的 ASC 一般挂在 PlayerState 上,这是社区里的主流做法,好处是角色死亡重生时,属性不会跟着角色一起销毁。代价是初始化变复杂了,因为 Character 和 PlayerState 的生命周期不同步。
服务器侧的入口是PossessedBy。Controller 占据 Pawn 时触发,此时 PlayerState 已经存在,可以安全地拿 ASC 并调InitAbilityActorInfo:
void APlayerCharacter::PossessedBy(AController* NewController) { Super::PossessedBy(NewController); // 服务器:PlayerState 已就位,拿 ASC 并绑定 ActorInfo InitAbilityActorInfo(); // 只在服务器做的事:给 GE、初始化属性 if (HasAuthority()) { AddCharacterAbilities(); InitializeDefaultAttributes(); } }客户端侧走的是另一条路。客户端的 PlayerState 是复制过来的,PossessedBy在客户端也会触发,但那个时候 PlayerState 上的 ASC 指针可能还是空的。可靠的做法是监听OnRep_PlayerState:
void APlayerCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); InitAbilityActorInfo(); // 客户端只绑 ActorInfo,不初始化属性 }两个函数里都调用InitAbilityActorInfo(),这是必须的,因为InitAbilityActorInfo(OwnerActor, AvatarActor)本质是告诉 ASC"你的逻辑拥有者是谁、你的身体是谁",服务器和客户端各自需要各自绑一次。客户端那次如果不调,ASC 的 AvatarActor 为空,所有依赖 Avatar 的能力、动画通知、GE 上下文全部失效,表现就是"技能能放但没伤害"。
InitAbilityActorInfo的实现我通常放在基类里,做成虚函数,让玩家角色和敌人类各自覆写:
void APlayerCharacter::InitAbilityActorInfo() { APlayerStateBase* PS = GetPlayerState<APlayerStateBase>(); check(PS); AbilitySystemComponent = PS->GetAbilitySystemComponent(); AttributeSet = PS->GetAttributeSet(); AbilitySystemComponent->InitAbilityActorInfo(PS, this); }注意:
InitializeDefaultAttributes()前面一定要加HasAuthority()或者IsLocallyControlled()之外的权威判断。我见过有人在两端都调,结果客户端的 GE 在本地跑了一遍,服务器复制过来又覆盖一遍,表现是每次进场景属性值随机,取决于两边的执行顺序。基础属性只能由服务器写。
4.2 敌人与 NPC:BeginPlay 就够了,但要加守卫
敌人的 ASC 一般直接挂在 Character 自己身上,没有 PlayerState 那层间接,初始化简单很多。常见做法是在BeginPlay里调InitAbilityActorInfo(this, this)然后初始化属性。
但这里有个细节要注意:AI 的 PossessedBy 触发时机早于 BeginPlay,如果你在 Character 构造函数里就把 ASC 建好了,那么在 BeginPlay 里初始化属性时 ASC 一定可用。但如果 ASC 是用GetOrCreate动态拿的,确保创建发生在初始化之前。
void AEnemyCharacter::BeginPlay() { Super::BeginPlay(); InitAbilityActorInfo(); // this 和 this 绑定 if (HasAuthority()) { InitializeDefaultAttributes(); } }重入守卫我建议做成基类里的一个 bool,bAttributesInitialized,初始 false,初始化成功后置 true。这样即使因为某种原因 BeginPlay 被重复调用(比如对象池复用),也不会重复应用 GE。
void ACharacterBase::InitializeDefaultAttributes() { if (bAttributesInitialized) return; bAttributesInitialized = true; ApplyEffectToSelf(DefaultPrimaryAttributes, GetCharacterLevel()); ApplyEffectToSelf(DefaultSecondaryAttributes, GetCharacterLevel()); ApplyEffectToSelf(DefaultVitalAttributes, GetCharacterLevel()); }注意这里我是先置 flag 再应用。原因是万一某个 GE 内部因为空指针崩了,至少不会在下一帧再崩一次,能让你看清第一条错误日志。
4.3 GE 初始化的完整配置与取值方式
真正干活的是一组 Instant 类型的 GameplayEffect。我拆成三条:主属性、次属性、关键属性。拆开的理由是前面说的依赖顺序,同时也方便不同职业复用其中某一条。
ApplyEffectToSelf的实现:
void ACharacterBase::ApplyEffectToSelf(TSubclassOf<UGameplayEffect> GameplayEffectClass, float Level) const { check(IsValid(GetAbilitySystemComponent())); check(GameplayEffectClass); FGameplayEffectContextHandle ContextHandle = GetAbilitySystemComponent()->MakeEffectContext(); ContextHandle.AddSourceObject(this); const FGameplayEffectSpecHandle SpecHandle = GetAbilitySystemComponent()->MakeOutgoingSpec(GameplayEffectClass, Level, ContextHandle); GetAbilitySystemComponent()->ApplyGameplayEffectSpecToTarget( *SpecHandle.Data.Get(), GetAbilitySystemComponent()); }MakeOutgoingSpec的第二个参数 Level 非常关键,它就是 CurveTable 求值的输入。角色 5 级、GE Level 传 5,那么Eval(5)就拿到 5 级对应的数值。如果这里传了默认的 1,你就会看到"升级后属性不涨"这个经典现象。
GE 内部每条 Modifier 的配置如下:
| 配置项 | 取值 | 说明 |
|---|---|---|
| Attribute | Strength / Agility / ... | 目标属性 |
| Modifier Op | Override | 直接覆盖兜底值,重复执行结果确定 |
| Magnitude Calculation Type | Scalable Float | 走曲线表 |
| Curve Table | CT_CharacterAttributes | 属性曲线表 |
| Row Name | Strength | 对应列名 |
| Level | 由 Spec 传入 | 即 MakeOutgoingSpec 的 Level 参数 |
这里选 Override 而不是 Add 是刻意的。用 Add 的前提是所有属性在构造函数里必须是 0,否则会叠加;而 0 值会导致初始化完成之前任何一次读取都拿到 0,UI 会出现"进游戏闪一下空血条"。用 Override 则可以保留构造函数里的兜底值,初始化时直接覆盖,两头都稳。
当然 Override 也有代价:多个 GE 都 Override 同一个属性时,只有最后执行的那个生效。初始化场景里这反而是我要的确定性,但如果后面要做"装备加成",那部分必须换成 Add 类型,绝不能也用 Override。
另一种初始化方式是SetByCaller,在 GE 的 Modifier 里选Set By Caller,Tag 填Attribute.Strength,代码里:
FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(GE_Init, 1.f, Context); SpecHandle.Data->SetSetByCallerMagnitude( FGameplayTag::RequestGameplayTag(FName("Attribute.Strength")), 15.f);这种方式适合数值来源不在曲线表里的场景,比如从存档读出来的属性、或者从服务器下发的配置。缺点是每次都要手写 Tag,属性一多容易漏,而且 Tag 拼错不会报错,只会静默传 0。所以我只在"读档"这条路上用它,常规初始化还是走曲线表。
5. 常见问题速查与排查思路
5.1 现象对照表
这张表是我自己整理的问题速查,基本覆盖了这套流程里 90% 的坑。
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 服务器正常,客户端属性全 0 | 客户端没调 InitAbilityActorInfo | 在 OnRep_PlayerState 里打断点确认 |
| 客户端血条闪一下后归零 | Health 夹取时 MaxHealth 还是 0 | 检查 GE 应用顺序,关键属性要最后 |
| 属性值翻倍 | 初始化跑了两次 | 加 bool 守卫,日志打印调用栈 |
| 升级后属性不变 | MakeOutgoingSpec 的 Level 传了 1 | 打印 Level 参数实际值 |
| GE 应用成功但数值没变 | Modifier 的 Attribute 选错或 Tag 拼错 | 对比 AttributeSet 里的属性名 |
| 技能能放但没伤害 | ASC 的 AvatarActor 为空 | 确认 InitAbilityActorInfo 传了 this |
| 客户端 UI 不刷新 | 漏了 REPNOTIFY_Always 或 OnRep 函数 | 检查 DOREPLIFETIME 宏 |
| MaxHealth 下降后血条超出 | 只夹取了 Health 没监听 MaxHealth | 在 PostGameplayEffectExecute 补分支 |
5.2 三个容易被忽略的检查点
第一个是ASC 的复制模式。如果 PlayerState 上的 ASC 设成了Mixed或者Minimal,属性复制的行为会跟预期不一致,客户端可能拿不到完整的属性数据。初始化阶段我一般用Full,等优化阶段再根据实际需求收窄。
第二个是GE 的 Duration Policy。初始化用的 GE 必须是Instant,如果误设成Infinite,属性会以一个"持续修饰"的形式存在,BaseValue 根本不变,你在服务器上读 BaseValue 会读到兜底值。这个坑很隐蔽,因为 UI 读 CurrentValue 看着是对的。
第三个是CurveTable 的行名大小写。曲线表的 Row Name 是大小写敏感的,代码里拼成 "strength" 而表里是 "Strength",Eval会返回 0 而不是报错。我现在的习惯是在初始化后打一条日志,把每个属性的最终值打出来,肉眼扫一遍,比断点快。
UE_LOG(LogTemp, Warning, TEXT("[Init] Lv%.0f STR=%.1f HP=%.1f/%.1f"), GetCharacterLevel(), AttributeSet->GetStrength(), AttributeSet->GetHealth(), AttributeSet->GetMaxHealth());提示:这条日志只在
HasAuthority()分支里打,客户端那条另打一条,两边对比就能立刻判断是"服务器没算对"还是"客户端没同步到",省掉一半排查时间。
6. 实操心得与后续扩展方向
6.1 几个只有踩过才知道的细节
别在构造函数里依赖其他属性。有人在 AttributeSet 构造函数里写MaxHealth = Strength * 10,看起来合理,实际执行时 Strength 还是 CDO 的默认值,而且每个实例的构造顺序不保证。属性之间的推导一律放到PostGameplayEffectExecute里做,那里才是数据真正就绪的地方。
Meta 属性不要设 ReplicatedUsing。Damage 这种值只在服务器上瞬间存在,复制出去没有任何意义,还会增加带宽。我在项目里给所有 Meta 属性统一不加复制标记,形成肌肉记忆。
加载存档时走另一条路。存档要存的是每个属性的 BaseValue,不是 CurrentValue。恢复时用SetNumericAttributeBase直接写回,绕开 GE,因为 GE 会触发一堆本不该在加载时触发的回调(比如死亡判定)。这条路和初始化 GE 是并行的两套,别混用。
初始化失败要有兜底。即使 GE 因为某种原因没应用成功(比如曲线表引用丢了),构造函数里的兜底值也能保证游戏不崩。我在打包前会专门跑一次"故意把曲线表置空"的测试,确认游戏能进、只是数值不对,而不是直接崩在空指针上。
6.2 这套结构往后怎么长
属性初始化搭好之后,往下扩展会顺很多。等级提升只需要重新应用一次初始化 GE(先把 BaseValue 清零再应用,或者用 Override 直接覆盖),装备加成走独立的 Add 型 GE,Buff 走带 Duration 的 GE,三层互不干扰。
再往后是属性预测。客户端预测属性变化需要 ASC 开预测键、GE 设成可预测,这块复杂度会上一个台阶,但基础还是这一套:BaseValue 由服务器权威写入,CurrentValue 由修饰派生。把初始化的链路理顺了,预测这部分才有讨论的前提。
还有一个方向是把初始化数据搬到 DataAsset 里,按职业、按怪物类型各配一份,Character 只持有一个引用。这样策划调数值完全不用碰蓝图,也不用碰代码,改资产重跑就行。我在几个模拟项目里都是这么收尾的,前期多花两小时做数据化,后面省下的时间远不止这些。
最后分享一个小习惯:每次改完初始化逻辑,我都会用一个"新号 + 老号 + 中途断线重连"三种情况各跑一遍。前两种覆盖首次初始化和属性恢复,第三种专门抓重入问题。三遍跑完没问题,这套初始化基本就稳了。