规划一下。
/ShooterCore/Game/B_Hero_ShooterMannequin.B_Hero_ShooterMannequin
Event Possessed => 权威 => 等待体验加载完成
=> 等库存组件和 QuickBar 组件都加载好。
QuickBar 就是右下角武器栏
他会加一个物品上去,默认是一把枪
/ShooterCore/Weapons/Pistol/ID_Pistol.ID_Pistol
同时也加给QuickBar,然后QuickBar 激活。
这个QuickBar激活一个index就相当于多个武器换来换去的哪个操作。默认激活0
这是一个 Controller Comp。他维护了武器的物品实例。
===
开搞
手枪物品流程整理
/ShooterCore/Weapons/Pistol/ID_Pistol.ID_Pistol
这是一个物品定义
里面有一些片段
- QuickBar 片段
class UInventoryFragment_QuickBarIcon : public UEqZeroInventoryItemFragment
配置一些给QuickBar的数据,这个比较简单,之前物品的时候就加了。
- 子弹片段
class UInventoryFragment_SetStats : public UEqZeroInventoryItemFragment
这个维护了一个 gameplay tag 到数字的映射,拿来维护枪械子弹的数量
- 可拾取片段
class UInventoryFragment_PickupIcon : public UEqZeroInventoryItemFragment
武器丢在地上可以被拾取,用这个来配置相关数据
- 准星片段
class UInventoryFragment_ReticleConfig : public ULyraInventoryItemFragment
weapons目录下的,用来配置准星相关的一些 UI
ULyraReticleWidgetBase 这是那个准星UI
- 装备片段
/ShooterCore/Weapons/Pistol/WID_Pistol.WID_Pistol
配置了一个装备定义 ULyraEquipmentDefinition
这里面包括
- 武器,也就是手枪的Actor
- 挂接socket,旋转
- 获得手枪后的技能集
- 物品实例的蓝图,会拿这个生成手枪实例。(有点复杂)
这个枪的实例是 /ShooterCore/Weapons/Pistol/B_WeaponInstance_Pistol.B_WeaponInstance_Pistol
父类是 /ShooterCore/Weapons/B_WeaponInstance_Base.B_WeaponInstance_Base
简单来说,一个武器实例基类蓝图,
他的子类是 手枪,步枪,霰弹枪,(netshoot是啥枪,根据引用关系,应该是霰弹枪的某种模式?)
B_WeaponInstance_Pistol 蓝图在往上到C++了
继承关系
ULyraRangedWeaponInstance=>ULyraWeaponInstance=>ULyraEquipmentInstance=>UObject
- ULyraEquipmentInstance
在设计中,一个装备首先是物品,物品片段上有一个装备的配置。
class UInventoryFragment_EquippableItem : public ULyraInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category=Lyra)
TSubclassOf<ULyraEquipmentDefinition> EquipmentDefinition;
};
所以装备的数据,是 Instigator 执行装备的物品实例
SpawnedActors 装备对应的Actor,例如手枪Actor,在手枪的装备定义中配置了一个手枪的模型Actor
UCLASS(BlueprintType, Blueprintable)
class ULyraEquipmentInstance : public UObject
{
GENERATED_BODY()
// 装备一般是通过物品来的,这个关联的是哪个物品的实例
UPROPERTY(ReplicatedUsing=OnRep_Instigator)
TObjectPtr<UObject> Instigator;
// 装备这个物品,可能要挂一堆模型的Actor,例如手枪
UPROPERTY(Replicated)
TArray<TObjectPtr<AActor>> SpawnedActors;
};
他的子类
- ULyraWeaponInstance
武器,多了
EquippedAnimSet,UneuippedAnimSet 穿上脱下装备要关联的动画层
记录所以才装备和开火的事件
DeviceProperties相关,自适应扳机配置,目前没有,例如设置PS5的手柄阻力反馈。
继续子类
- ULyraRangedWeaponInstance
远程武器,定义了一些枪的伤害计算,例如散射逻辑的变量。
为什么这么设计。
装备:例如铠甲,头盔,纯粹的外观
武器:装备中的一部分,不同的武器代表不同的动画层。可能有近战武器,远程武器如枪械
远程武器:特质枪械,封装一些攻击的子弹散射的逻辑。Lyra目前没有近战武器。
武器逻辑落地
先从角色初始化,体验加载,获得一把手枪开始。
/Game/Weapons/B_Weapon.B_Weapon
武器Actor,目前什么都没有,Mesh都没有。
装备实例配置
接下来完成这一条线
ULyraRangedWeaponInstance=>ULyraWeaponInstance=>ULyraEquipmentInstance=>
UCLASS(BlueprintType, Blueprintable)
class UEqZeroEquipmentInstance : public UObject
{
GENERATED_BODY()
private:
// 触发装备的源头(如背包物品实例),网络复制
UPROPERTY(ReplicatedUsing=OnRep_Instigator)
TObjectPtr<UObject> Instigator;
// 装备生成的Actor列表,网络复制,例如枪带mesh的Actor
UPROPERTY(Replicated)
TArray<TObjectPtr<AActor>> SpawnedActors;
};
除此之外就是 SpawnEquipmentActors 外部调用加载装备actor到角色身上。和一些事件OnEquipped,OnUnequipped
/**
* UEqZeroWeaponInstance
*
* 武器实例
* - 设备属性:例如PS5手柄的自适应扳机配置
* - 动画配置:不同的武器可能会链接不同的动画层
* - 武器使用时间逻辑:例如可以根据上次使用时间来判断是否触发第一枪精准等
*/
UCLASS()
class UEqZeroWeaponInstance : public UEqZeroEquipmentInstance
{
GENERATED_BODY()
protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Animation)
FEqZeroAnimLayerSelectionSet EquippedAnimSet;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Animation)
FEqZeroAnimLayerSelectionSet UneuippedAnimSet;
double TimeLastEquipped = 0.0;
double TimeLastFired = 0.0;
};
DeviceProperties 设备属性的作用是,例如PS5的自适应扳机,根据不同的武器有不同的配置。但是当前线不搞。
动画配置是用于,例如不同的武器链接不同的动画层。之前做动画的时候我们是默认写死的。
装备和开火的时间记录和维护
/**
* UEqZeroRangedWeaponInstance
*
* 远程武器实例,继承自UEqZeroWeaponInstance,增加了远程武器特有的属性和逻辑,例如弹药、扩散等。
*/
UCLASS()
class UEqZeroRangedWeaponInstance : public UEqZeroWeaponInstance, public IEqZeroAbilitySourceInterface {};
这个类属性有点多,主要是开火散射用的
首先 IEqZeroAbilitySourceInterface 提供了距离衰减和包头物理材质的接口,提供给伤害流程。
总之子类 /EqZeroCore/Weapons/B_WeaponInstance_Base.B_WeaponInstance_Base 武器基类蓝图创建了
B_WeaponInstance_Base
这个蓝图里面做的就是之前我们动画蓝图写死的那个,直接链接动画城的逻辑
目前是空的
创建他的子类手枪,这个存粹是配置了
/EqZeroCore/Weapons/Pistol/B_WeaponInstance_Pistol.B_WeaponInstance_Pistol
非常费劲的配置好了
装备定义
/ShooterCore/Weapons/Pistol/AbilitySet_ShooterPistol.AbilitySet_ShooterPistol
手枪的技能集合,开火,换子弹。等做技能的时候在配
武器Actor和mesh
/Game/Weapons/B_Weapon.B_Weapon 这个还是崩的,把队伍的接口删除就好了
/EqZeroCore/Weapons/Pistol/B_Pistol.B_Pistol
配置手枪的Actor,这里面主要是mesh,特效,还有一个动画蓝图的配置
动画蓝图只有一个slot
/EqZeroCore/Weapons/Pistol/WID_Pistol.WID_Pistol
然后完成了装备定义的配置,包括上面的
- 武器实例类
- 技能集
- 武器Actor
武器的物品定义
/EqZeroCore/Weapons/Pistol/ID_Pistol.ID_Pistol
- 装备片段
- QuickBar 装备栏
- SetStats 用于标记子弹
- 可拾取的配置
- 准星UI(跳过,UI晚点做)
QuickBar
/EqZeroCore/Game/B_QuickBarComponent.B_QuickBarComponent
他说通过体验的技能集,添加上来的,双端组件
/EqZeroCore/Experiences/LAS_ShooterGame_StandardComponents
UCLASS(Blueprintable, meta=(BlueprintSpawnableComponent))
class UEqZeroQuickBarComponent : public UControllerComponent
{
GENERATED_BODY()
protected:
UPROPERTY()
int32 NumSlots = 3;
private:
UPROPERTY(ReplicatedUsing=OnRep_Slots)
TArray<TObjectPtr<UEqZeroInventoryItemInstance>> Slots;
UPROPERTY(ReplicatedUsing=OnRep_ActiveSlotIndex)
int32 ActiveSlotIndex = -1;
UPROPERTY()
TObjectPtr<UEqZeroEquipmentInstance> EquippedItem;
};
Slots 装备栏能装3把武器,数组[nullper, nullper, nullptr]
ActiveSlotIndex 当前激活的武器,激活这把武器,会触发装备系统的逻辑
并且缓存装备实例 EquippedItem
核心逻辑,激活某一把武器,此时Slot这个位置应该是有武器的。
调用的是装备组件的穿上和脱下
void UEqZeroQuickBarComponent::SetActiveSlotIndex_Implementation(int32 NewIndex)
{
if (Slots.IsValidIndex(NewIndex) && (ActiveSlotIndex != NewIndex))
{
// 卸下当前 -> 切换索引 -> 穿上新装备
UnEquipItemInSlot();
ActiveSlotIndex = NewIndex;
EquipItemInSlot();
OnRep_ActiveSlotIndex();
}
}
装备组件
UCLASS(BlueprintType, Const)
class UEqZeroEquipmentManagerComponent : public UPawnComponent
{
GENERATED_BODY()
private:
UPROPERTY(Replicated)
FEqZeroEquipmentList EquipmentList;
};
FastArray 维护的 装备对象。
他需要处理装上装备后,添加模型Actor,和添加技能的逻辑。
/**
* 已应用的装备条目(FastArraySerializer元素)
* 每个条目对应一件已装备的装备,包含装备定义、实例引用和已授予的技能句柄
*/
USTRUCT(BlueprintType)
struct FEqZeroAppliedEquipmentEntry : public FFastArraySerializerItem
{
GENERATED_BODY()
// ...
// 装备定义类(描述装备的配置数据)
UPROPERTY()
TSubclassOf<UEqZeroEquipmentDefinition> EquipmentDefinition;
// 装备实例(运行时创建的对象)
UPROPERTY()
TObjectPtr<UEqZeroEquipmentInstance> Instance = nullptr;
// 已授予的技能句柄,仅在Authority端有效,用于卸下时撤销技能
UPROPERTY(NotReplicated)
FEqZeroAbilitySet_GrantedHandles GrantedHandles;
};
接下来跑通,装备武器的流程

首先是配置了一个武器的初始化列表。这里放的是武器物品的定义。加到库存系统,加到QuickBar。这个都是双端的组件

加到QuickBar 才会触发 Equip 装备组件才会获取他。

然后就是默认选中这个武器了。
QuickBar 激活index的时候
会脱下当前装备,然后穿上下一个新装备
void UEqZeroQuickBarComponent::SetActiveSlotIndex_Implementation(int32 NewIndex)
{
if (Slots.IsValidIndex(NewIndex) && (ActiveSlotIndex != NewIndex))
{
// 卸下当前 -> 切换索引 -> 穿上新装备
UnEquipItemInSlot();
ActiveSlotIndex = NewIndex;
EquipItemInSlot();
OnRep_ActiveSlotIndex();
}
}
quick bar 装备的时候,会调用装备组件去 装备,然后自己获得一个对象引用。
void UEqZeroQuickBarComponent::EquipItemInSlot()
{
check(Slots.IsValidIndex(ActiveSlotIndex));
check(EquippedItem == nullptr);
if (UEqZeroInventoryItemInstance* SlotItem = Slots[ActiveSlotIndex])
{
// 从道具的可装备片段中获取装备定义
if (const UInventoryFragment_EquippableItem* EquipInfo = SlotItem->FindFragmentByClass<UInventoryFragment_EquippableItem>())
{
TSubclassOf<UEqZeroEquipmentDefinition> EquipDef = EquipInfo->EquipmentDefinition;
if (EquipDef != nullptr)
{
if (UEqZeroEquipmentManagerComponent* EquipmentManager = FindEquipmentManager())
{
EquippedItem = EquipmentManager->EquipItem(EquipDef);
if (EquippedItem != nullptr)
{
// 将道具实例设为装备的 Instigator,方便技能回溯到源道具
EquippedItem->SetInstigator(SlotItem);
}
}
}
}
}
}
装备加物品,他维护的是装备对象的FastArray
重点在 AddEntry中
UEqZeroEquipmentInstance* UEqZeroEquipmentManagerComponent::EquipItem(TSubclassOf<UEqZeroEquipmentDefinition> EquipmentClass)
{
UEqZeroEquipmentInstance* Result = nullptr;
if (EquipmentClass != nullptr)
{
Result = EquipmentList.AddEntry(EquipmentClass);
if (Result != nullptr)
{
Result->OnEquipped();
// 注册为复制子对象,使装备实例可以通过网络同步
if (IsUsingRegisteredSubObjectList() && IsReadyForReplication())
{
AddReplicatedSubObject(Result);
}
}
}
return Result;
}
服务器会创建 装备实例,赋予角色相关技能,生成武器模型。这时候我们的枪就有了
UEqZeroEquipmentInstance* FEqZeroEquipmentList::AddEntry(TSubclassOf<UEqZeroEquipmentDefinition> EquipmentDefinition)
{
UEqZeroEquipmentInstance* Result = nullptr;
check(EquipmentDefinition != nullptr);
check(OwnerComponent);
check(OwnerComponent->GetOwner()->HasAuthority());
// 从装备定义的CDO获取配置信息
const UEqZeroEquipmentDefinition* EquipmentCDO = GetDefault<UEqZeroEquipmentDefinition>(EquipmentDefinition);
TSubclassOf<UEqZeroEquipmentInstance> InstanceType = EquipmentCDO->InstanceType;
if (InstanceType == nullptr)
{
InstanceType = UEqZeroEquipmentInstance::StaticClass();
}
// 创建新条目并生成装备实例对象
FEqZeroAppliedEquipmentEntry& NewEntry = Entries.AddDefaulted_GetRef();
NewEntry.EquipmentDefinition = EquipmentDefinition;
// 注意:Outer 使用 Actor 而不是 Component,这是因为 UE 的子对象复制机制要求(UE-127172)
NewEntry.Instance = NewObject<UEqZeroEquipmentInstance>(OwnerComponent->GetOwner(), InstanceType);
Result = NewEntry.Instance;
// 授予装备定义中配置的技能集
if (UEqZeroAbilitySystemComponent* ASC = GetAbilitySystemComponent())
{
for (const TObjectPtr<const UEqZeroAbilitySet>& AbilitySet : EquipmentCDO->AbilitySetsToGrant)
{
AbilitySet->GiveToAbilitySystem(ASC, &NewEntry.GrantedHandles, Result);
}
}
else
{
// TODO: 如果获取不到ASC,考虑添加警告日志
}
// 生成装备相关的Actor(武器模型等)
Result->SpawnEquipmentActors(EquipmentCDO->ActorsToSpawn);
MarkItemDirty(NewEntry);
return Result;
}
正式的链接动画层逻辑
我们把链接动画层的逻辑断掉,写死的那个变成T-pose了
/EqZeroCore/Weapons/B_WeaponInstance_Base.B_WeaponInstance_Base
原版的逻辑是写在这个蓝图的。

这里逻辑就是根据
/Game/Characters/Cosmetics/B_PickRandomCharacter.B_PickRandomCharacter
这里面
/Game/Characters/Cosmetics/B_Manny.B_Manny
这个身上的TAG

拿到 AnimationStyle
在对比一下这个
/EqZeroCore/Weapons/Pistol/B_WeaponInstance_Pistol.B_WeaponInstance_Pistol

拿到动画层。
代替的是我们原来写死动画层的逻辑。
开火技能
UEqZeroRangedWeaponInstance 目前还是空的,结合技能一起加
UEqZeroGameplayAbility_RangedWeapon 开火技能
CanActivateAbility
首先 UEqZeroGameplayAbility_FromEquipment 是父类
封装了技能的施法对象,例如必须有枪(UEqZeroRangedWeaponInstance)才能开火
UEqZeroRangedWeaponInstance* UEqZeroGameplayAbility_RangedWeapon::GetWeaponInstance() const
{
return Cast<UEqZeroRangedWeaponInstance>(GetAssociatedEquipment());
}
bool UEqZeroGameplayAbility_RangedWeapon::CanActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
bool bResult = Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags);
if (bResult)
{
if (GetWeaponInstance() == nullptr)
{
// LOG
bResult = false;
}
}
return bResult;
}
ActivateAbility
void UEqZeroGameplayAbility_RangedWeapon::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
// Bind target data callback
UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
check(MyAbilityComponent);
OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).AddUObject(this, &ThisClass::OnTargetDataReadyCallback);
// 更新武器的开火时间
UEqZeroRangedWeaponInstance* WeaponData = GetWeaponInstance();
check(WeaponData);
WeaponData->UpdateFiringTime();
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
}
这个 OnTargetDataReadyCallback 什么时候会被调用呢?
这个技能是本地预测,客户端执行。
客户端打包 FGameplayAbilityTargetDataHandle 通过 CallServerSetReplicatedTargetData 发送服务器。
服务器在AbilityTargetDataSetDelegate的时候监听,等到数据包后开始继续的逻辑。
EndAbility
这个比较短,看看
void UEqZeroGameplayAbility_RangedWeapon::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
if (IsEndAbilityValid(Handle, ActorInfo))
{
if (ScopeLockCount > 0)
{
WaitingToExecute.Add(FPostLockDelegate::CreateUObject(this, &ThisClass::EndAbility, Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled));
return;
}
UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
check(MyAbilityComponent);
// 能力结束时,消耗目标数据并移除委托
MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).Remove(OnTargetDataReadyCallbackDelegateHandle);
MyAbilityComponent->ConsumeClientReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey());
Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}
}
为什么有 ScopeLockCount
客户端发出的 TargetData(命中数据)并不是发完就没了,它会被服务器的 AbilitySystemComponent (ASC) 缓存起来,以防网络丢包或乱序时需要重用,或者供迟到的逻辑读取。
所以我理解,你用了这个TargetData代理就需要 ScopeLockCount,这个反正都是Ability基类的东西。
下面两行也是。
OnTargetDataReadyCallback
void UEqZeroGameplayAbility_RangedWeapon::OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag)
{
UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
check(MyAbilityComponent);
if (const FGameplayAbilitySpec* AbilitySpec = MyAbilityComponent->FindAbilitySpecFromHandle(CurrentSpecHandle))
{
// 开启预测窗口,用于网络同步时的平滑处理
FScopedPredictionWindow ScopedPrediction(MyAbilityComponent);
// 移动语义获取数据,避免拷贝。获取目标数据的所有权,以确保不会有游戏代码的回调在我们不知情的情况下使数据失效。
FGameplayAbilityTargetDataHandle LocalTargetDataHandle(MoveTemp(const_cast<FGameplayAbilityTargetDataHandle&>(InData)));
// 如果是客户端,必须调用这个函数
if (const bool bShouldNotifyServer = CurrentActorInfo->IsLocallyControlled() && !CurrentActorInfo->IsNetAuthority())
{
MyAbilityComponent->CallServerSetReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey(), LocalTargetDataHandle, ApplicationTag, MyAbilityComponent->ScopedPredictionKey);
}
const bool bIsTargetDataValid = true;
bool bProjectileWeapon = false;
#if WITH_SERVER_CODE
// 服务器的命中确认
if (!bProjectileWeapon)
{
if (AController* Controller = GetControllerFromActorInfo())
{
if (Controller->GetLocalRole() == ROLE_Authority)
{
if (UEqZeroWeaponStateComponent* WeaponStateComponent = Controller->FindComponentByClass<UEqZeroWeaponStateComponent>())
{
TArray<uint8> HitReplaces;
for (uint8 i = 0; (i < LocalTargetDataHandle.Num()) && (i < 255); ++i)
{
if (const FGameplayAbilityTargetData_SingleTargetHit* SingleTargetHit = static_cast<const FGameplayAbilityTargetData_SingleTargetHit*>(LocalTargetDataHandle.Get(i)))
{
if (SingleTargetHit->bHitReplaced)
{
HitReplaces.Add(i);
}
}
}
WeaponStateComponent->ClientConfirmTargetData(LocalTargetDataHandle.UniqueId, bIsTargetDataValid, HitReplaces);
}
}
}
}
#endif //WITH_SERVER_CODE
// 检查是否还有弹药或满足其他消耗条件
if (bIsTargetDataValid && CommitAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo))
{
// 增加武器散布(后坐力影响)
UEqZeroRangedWeaponInstance* WeaponData = GetWeaponInstance();
check(WeaponData);
WeaponData->AddSpread();
// 蓝图实现:触发伤害、播放特效等
OnRangedWeaponTargetDataReady(LocalTargetDataHandle);
}
else
{
// 如果无法提交(例如没子弹了),则打印警告并结束技能
UE_LOG(LogEqZeroAbilitySystem, Warning, TEXT("Weapon ability %s failed to commit (bIsTargetDataValid=%d)"), *GetPathName(), bIsTargetDataValid ? 1 : 0);
K2_EndAbility();
}
}
// 标记数据已被处理,防止重复使用
MyAbilityComponent->ConsumeClientReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey());
}
UEqZeroWeaponStateComponent
上面用到了 UEqZeroWeaponStateComponent->ClientConfirmTargetData
中途插入了一个奇怪的东西,这个是通过 game future 添加到 Controller 上的。双端组件。
武器命中反馈(Hit Markers)和伤害确认
UCLASS()
class UEqZeroWeaponStateComponent : public UControllerComponent
{
GENERATED_BODY()
private:
/**
* 上一次伤害标记的时间,如果是网络延迟 0.1s 以前的标记也不需要表现了
*/
double LastWeaponDamageInstigatedTime = 0.0;
/**
* 我们最近发起的武器伤害(已确认命中)的屏幕空间位置
*/
TArray<FEqZeroScreenSpaceHitLocation> LastWeaponDamageScreenLocations;
/**
* 未经证实的命中
*/
TArray<FEqZeroServerSideHitMarkerBatch> UnconfirmedServerSideHitMarkers;
};
客户端设计,把命中加到一个列表,交给服务器确认,服务器确认后再会给客户端说确认好了。
这样可以做一些,先行的逻辑,确认后的表现效果。
UI会拿到这个列表做显示。
哪里发起调用还没看完,回头再细化。
开火蓝图
有一些函数找不到调用的地方。翻了一下,果然是写在蓝图里面
/Game/Weapons/GA_Weapon_Fire.GA_Weapon_Fire
他的具体子类,例如手枪啥的,就只是配置用的
蓝图的激活技能,Local Control 调用 UEqZeroGameplayAbility_RangedWeapon::StartRangedWeaponTargeting
void UEqZeroGameplayAbility_RangedWeapon::StartRangedWeaponTargeting()
{
check(CurrentActorInfo);
AActor* AvatarActor = CurrentActorInfo->AvatarActor.Get();
check(AvatarActor);
UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
check(MyAbilityComponent);
AController* Controller = GetControllerFromActorInfo();
check(Controller);
UEqZeroWeaponStateComponent* WeaponStateComponent = Controller->FindComponentByClass<UEqZeroWeaponStateComponent>();
// 预测窗口
// 它告诉系统:接下来的操作(如造成伤害、消耗子弹)是我客户端先“猜”的,请在服务器确认前先暂时这么显示。
// 如果没有客户端的 CallServerSetReplicatedTargetData 会有问题
FScopedPredictionWindow ScopedPrediction(MyAbilityComponent, CurrentActivationInfo.GetActivationPredictionKey());
// 执行本地命中检测
// 读取摄像机位置、计算散布角度、发射射线(LineTrace/Sweep),并最终填充 FoundHits 数组。这是实际算出“我打中了谁”的一步
TArray<FHitResult> FoundHits;
PerformLocalTargeting(FoundHits);
// 构建 TargetData
FGameplayAbilityTargetDataHandle TargetData;
TargetData.UniqueId = WeaponStateComponent ? WeaponStateComponent->GetUnconfirmedServerSideHitMarkerCount() : 0;
if (FoundHits.Num() > 0)
{
const int32 CartridgeID = FMath::Rand();
for (const FHitResult& FoundHit : FoundHits)
{
// 创建 GAS 标准的单点命中数据结构
FEqZeroGameplayAbilityTargetData_SingleTargetHit* NewTargetData = new FEqZeroGameplayAbilityTargetData_SingleTargetHit();
NewTargetData->HitResult = FoundHit;
NewTargetData->CartridgeID = CartridgeID;
TargetData.Add(NewTargetData);
}
}
// 在本地先记录这次命中,以便在UI上立即显示(比如先画个白色的X)
// 虽然还没经服务器确认,但为了手感需要即时反馈
if (WeaponStateComponent != nullptr)
{
WeaponStateComponent->AddUnconfirmedServerSideHitMarkers(TargetData, FoundHits);
}
// 立即处理这组数据
OnTargetDataReadyCallback(TargetData, FGameplayTag());
}
这里命中的检测,需要好好看看武器上一些逻辑了,例如扩散的计算等。
UEqZeroRangedWeaponInstance
OnEquipped
数值的初始化
void UEqZeroRangedWeaponInstance::OnEquipped()
{
Super::OnEquipped();
// 计算热度的最大最小范围,然后取中值为当前热度
float MinHeatRange;
float MaxHeatRange;
ComputeHeatRange(MinHeatRange, MaxHeatRange);
CurrentHeat = (MinHeatRange + MaxHeatRange) * 0.5f; // 对于手枪,这个范围是 0, 8,所以初始热度是 4
//然后根据这条曲线,曲线类似 y=kx正比例,但是有弧度,结果取了个中点。对于手枪这个值是 7.25,就是线段取了一个中点
CurrentSpreadAngle = HeatToSpreadCurve.GetRichCurveConst()->Eval(CurrentHeat);
// 这些倍数目前都是 1.0
CurrentSpreadAngleMultiplier = 1.0f;
StandingStillMultiplier = 1.0f;
JumpFallMultiplier = 1.0f;
CrouchingMultiplier = 1.0f;
}
void UEqZeroRangedWeaponInstance::ComputeHeatRange(float& MinHeat, float& MaxHeat)
{
float Min1;
float Max1;
HeatToHeatPerShotCurve.GetRichCurveConst()->GetTimeRange(Min1, Max1); // 0, 0
float Min2;
float Max2;
HeatToCoolDownPerSecondCurve.GetRichCurveConst()->GetTimeRange(Min2, Max2); // 0, 0
float Min3;
float Max3;
HeatToSpreadCurve.GetRichCurveConst()->GetTimeRange(Min3, Max3); // 0, 8
// 对于手枪pistol 最后结果是 0, 8
MinHeat = FMath::Min(FMath::Min(Min1, Min2), Min3);
MaxHeat = FMath::Max(FMath::Max(Max1, Max2), Max3);
}
tick
主要是通过热度,算出扩散角度,给外部度。这里只是转换,热度的维护在其他地方
bool UEqZeroRangedWeaponInstance::UpdateSpread(float DeltaSeconds)
{
const float TimeSinceFired = GetWorld()->TimeSince(LastFireTime);
// 开火后,超过这个时间,热度下降
if (TimeSinceFired > SpreadRecoveryCooldownDelay)
{
// 根据当前热量决定冷却速度(例如:热量越高,冷却越快)。
// 但是 HeatToCoolDownPerSecondCurve 曲线目前只有一个 (0, 10)的点
const float CooldownRate = HeatToCoolDownPerSecondCurve.GetRichCurveConst()->Eval(CurrentHeat);
// 减少热量,然后修改当前扩散角度
CurrentHeat = ClampHeat(CurrentHeat - (CooldownRate * DeltaSeconds));
CurrentSpreadAngle = HeatToSpreadCurve.GetRichCurveConst()->Eval(CurrentHeat);
}
float MinSpread;
float MaxSpread;
ComputeSpreadRange(MinSpread, MaxSpread);
// 是否已经恢复到了最小扩散状态(用于判断首发精准度), 最小扩散点是曲线的中点,上面几行
return FMath::IsNearlyEqual(CurrentSpreadAngle, MinSpread, KINDA_SMALL_NUMBER);
}
这里主要计算的是运动状态对准星扩散的影响。
例如静止,蹲伏,开镜,就打得准,跳跃就不准
bool UEqZeroRangedWeaponInstance::UpdateMultipliers(float DeltaSeconds)
{
const float MultiplierNearlyEqualThreshold = 0.05f;
APawn* Pawn = GetPawn();
check(Pawn != nullptr);
UCharacterMovementComponent* CharMovementComp = Cast<UCharacterMovementComponent>(Pawn->GetMovementComponent());
/*
* 计算静止 对于准星 散射的影响
* - 把速度PawnSpeed MapRangeClamped 对于手枪数值是 [80, 80+20] -> [0.9, 1.0],也就是说当速度在0-80时,乘数是0.9,超过100时乘数是1.0,在80-100之间平滑过渡
* - StandingStillMultiplier 会平滑过渡到这个目标值,过渡速率由 TransitionRate_StandingStill 默认5.f
* - bStandingStillMultiplierAtMin 用于判断当前乘数是否已经接近最小值(也就是玩家是否基本上处于静止状态)
*/
const float PawnSpeed = Pawn->GetVelocity().Size();
const float MovementTargetValue = FMath::GetMappedRangeValueClamped(
FVector2D(StandingStillSpeedThreshold, StandingStillSpeedThreshold + StandingStillToMovingSpeedRange),
FVector2D(SpreadAngleMultiplier_StandingStill, 1.0f),
PawnSpeed);
StandingStillMultiplier = FMath::FInterpTo(StandingStillMultiplier, MovementTargetValue, DeltaSeconds, TransitionRate_StandingStill);
const bool bStandingStillMultiplierAtMin = FMath::IsNearlyEqual(StandingStillMultiplier, SpreadAngleMultiplier_StandingStill, SpreadAngleMultiplier_StandingStill*0.1f);
/*
* 蹲伏时的乘数
* - 如果角色正在蹲伏,目标值是 SpreadAngleMultiplier_Crouching (手枪数值0.65)
* - CrouchingMultiplier 会平滑过渡到这个目标值,过渡速率由 TransitionRate_Crouching 默认5.f
*/
const bool bIsCrouching = (CharMovementComp != nullptr) && CharMovementComp->IsCrouching();
const float CrouchingTargetValue = bIsCrouching ? SpreadAngleMultiplier_Crouching : 1.0f;
CrouchingMultiplier = FMath::FInterpTo(CrouchingMultiplier, CrouchingTargetValue, DeltaSeconds, TransitionRate_Crouching);
const bool bCrouchingMultiplierAtTarget = FMath::IsNearlyEqual(CrouchingMultiplier, CrouchingTargetValue, MultiplierNearlyEqualThreshold);
/*
* 跳跃/下落时的乘数
* - 如果角色正在跳跃或下落,目标值是 SpreadAngleMultiplier_JumpingOrFalling (手枪数值1.25)
* - JumpFallMultiplier 会平滑过渡到这个目标值,过渡速率由 TransitionRate_JumpingOrFalling 默认5.f
*/
const bool bIsJumpingOrFalling = (CharMovementComp != nullptr) && CharMovementComp->IsFalling();
const float JumpFallTargetValue = bIsJumpingOrFalling ? SpreadAngleMultiplier_JumpingOrFalling : 1.0f;
JumpFallMultiplier = FMath::FInterpTo(JumpFallMultiplier, JumpFallTargetValue, DeltaSeconds, TransitionRate_JumpingOrFalling);
const bool bJumpFallMultiplerIs1 = FMath::IsNearlyEqual(JumpFallMultiplier, 1.0f, MultiplierNearlyEqualThreshold);
/*
* 判断是否在瞄准 然后计算贡献
* -
*/
float AimingAlpha = 0.0f;
if (const UEqZeroCameraComponent* CameraComponent = UEqZeroCameraComponent::FindCameraComponent(Pawn))
{
float TopCameraWeight;
FGameplayTag TopCameraTag;
CameraComponent->GetBlendInfo(TopCameraWeight, TopCameraTag);
AimingAlpha = (TopCameraTag == TAG_EqZero_Weapon_SteadyAimingCamera) ? TopCameraWeight : 0.0f;
}
const float AimingMultiplier = FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 1.0f),
FVector2D(1.0f, SpreadAngleMultiplier_Aiming),
AimingAlpha);
const bool bAimingMultiplierAtTarget = FMath::IsNearlyEqual(AimingMultiplier, SpreadAngleMultiplier_Aiming, KINDA_SMALL_NUMBER);
/*
* 把上面的几个乘数综合起来,得到最终的扩散角乘数 CurrentSpreadAngleMultiplier
*/
const float CombinedMultiplier = AimingMultiplier * StandingStillMultiplier * CrouchingMultiplier * JumpFallMultiplier;
CurrentSpreadAngleMultiplier = CombinedMultiplier;
// 需要处理这些点差乘数,它们表明我们并未处于最小点差状态。
return bStandingStillMultiplierAtMin && bCrouchingMultiplierAtTarget && bJumpFallMultiplerIs1 && bAimingMultiplierAtTarget;
}
这两个函数在 tick 中调用
void UEqZeroRangedWeaponInstance::Tick(float DeltaSeconds)
{
APawn* Pawn = GetPawn();
check(Pawn != nullptr);
const bool bMinSpread = UpdateSpread(DeltaSeconds);
const bool bMinMultipliers = UpdateMultipliers(DeltaSeconds);
// 不处于任何扩散状态,且配置允许首发精准度。那么下一次射击,就没有扩散,超级准的一枪
bHasFirstShotAccuracy = bAllowFirstShotAccuracy && bMinMultipliers && bMinSpread;
#if WITH_EDITOR
UpdateDebugVisualization();
#endif
}
还有一些外部接口
GAS伤害计算的时候调用过这两个接口
float UEqZeroRangedWeaponInstance::GetDistanceAttenuation(float Distance, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags) const
{
// 伤害计算那边会获取武器的距离衰减曲线。技能的 Execution 那里调用,超过距离就是0
const FRichCurve* Curve = DistanceDamageFalloff.GetRichCurveConst();
return Curve->HasAnyData() ? Curve->Eval(Distance) : 1.0f;
}
float UEqZeroRangedWeaponInstance::GetPhysicalMaterialAttenuation(const UPhysicalMaterial* PhysicalMaterial, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags) const
{
float CombinedMultiplier = 1.0f;
if (const UPhysicalMaterialWithTags* PhysMatWithTags = Cast<const UPhysicalMaterialWithTags>(PhysicalMaterial))
{
for (const FGameplayTag MaterialTag : PhysMatWithTags->Tags)
{
if (const float* pTagMultiplier = MaterialDamageMultiplier.Find(MaterialTag))
{
CombinedMultiplier *= *pTagMultiplier;
}
}
}
return CombinedMultiplier;
}
开火蓝图
绕了一圈又回来了。
开火的时候 StartRangedWeaponTargeting 除了一些网络相关的预测逻辑。另一部分是击中目标是如何计算的
// 执行本地命中检测
// 读取摄像机位置、计算散布角度、发射射线(LineTrace/Sweep),并最终填充 FoundHits 数组。这是实际算出“我打中了谁”的一步
TArray<FHitResult> FoundHits;
PerformLocalTargeting(FoundHits);
void UEqZeroGameplayAbility_RangedWeapon::PerformLocalTargeting(OUT TArray<FHitResult>& OutHits)
{
APawn* const AvatarPawn = Cast<APawn>(GetAvatarActorFromActorInfo());
UEqZeroRangedWeaponInstance* WeaponData = GetWeaponInstance();
if (AvatarPawn && AvatarPawn->IsLocallyControlled() && WeaponData)
{
// 首先要封装一个开火输入结构
FRangedWeaponFiringInput InputData;
InputData.WeaponData = WeaponData;
InputData.bCanPlayBulletFX = (AvatarPawn->GetNetMode() != NM_DedicatedServer); // 客户端需要播放子弹特效,服务器不需要
// 当玩家靠近墙壁时,这里应该执行更复杂的逻辑(官方注释)
const FTransform TargetTransform = GetTargetingTransform(AvatarPawn, EEqZeroAbilityTargetingSource::CameraTowardsFocus);
InputData.AimDir = TargetTransform.GetUnitAxis(EAxis::X);
InputData.StartTrace = TargetTransform.GetTranslation();
InputData.EndAim = InputData.StartTrace + InputData.AimDir * WeaponData->GetMaxDamageRange();
// 弹道模拟
TraceBulletsInCartridge(InputData, OutHits);
}
}
准备输入结构,然后调用 TraceBulletsInCartridge
void UEqZeroGameplayAbility_RangedWeapon::TraceBulletsInCartridge(const FRangedWeaponFiringInput& InputData, OUT TArray<FHitResult>& OutHits)
{
UEqZeroRangedWeaponInstance* WeaponData = InputData.WeaponData;
check(WeaponData);
// 遍历每一发子弹,进行弹道模拟。通常是1,特殊情况是散弹枪
const int32 BulletsPerCartridge = WeaponData->GetBulletsPerCartridge();
for (int32 BulletIndex = 0; BulletIndex < BulletsPerCartridge; ++BulletIndex)
{
// 扩散角度,单位是度,他收到热度的影响, 例如持续开火会增加热度,增加热度会增加扩散角度
const float BaseSpreadAngle = WeaponData->GetCalculatedSpreadAngle();
// 扩散系数,例如蹲下,静止不动,瞄准,会拥有一个<1的系数。乘到角度上就是射的更准了,在空中更不准。
const float SpreadAngleMultiplier = WeaponData->GetCalculatedSpreadAngleMultiplier();
// 两个相乘得到实际的扩散角度
const float ActualSpreadAngle = BaseSpreadAngle * SpreadAngleMultiplier;
const float HalfSpreadAngleInRadians = FMath::DegreesToRadians(ActualSpreadAngle * 0.5f);
const FVector BulletDir = VRandConeNormalDistribution(InputData.AimDir, HalfSpreadAngleInRadians, WeaponData->GetSpreadExponent());
const FVector EndTrace = InputData.StartTrace + (BulletDir * WeaponData->GetMaxDamageRange());
FVector HitLocation = EndTrace;
TArray<FHitResult> AllImpacts;
// 对这样的射线做一次检测
FHitResult Impact = DoSingleBulletTrace(InputData.StartTrace, EndTrace, WeaponData->GetBulletTraceSweepRadius(), false, AllImpacts);
const AActor* HitActor = Impact.GetActor();
if (HitActor)
{
#if ENABLE_DRAW_DEBUG
if (EqZeroConsoleVariables::DrawBulletHitDuration > 0.0f)
{
DrawDebugPoint(GetWorld(), Impact.ImpactPoint, EqZeroConsoleVariables::DrawBulletHitRadius, FColor::Red, false, EqZeroConsoleVariables::DrawBulletHitRadius);
}
#endif
// 合并所有的命中结果,这里可能是 霰弹枪中的一发射线的检测
if (AllImpacts.Num() > 0)
{
OutHits.Append(AllImpacts);
}
HitLocation = Impact.ImpactPoint;
}
// 确保 OutHits 中始终有一个条目,这样方向就可以用于追踪器等。
if (OutHits.Num() == 0)
{
if (!Impact.bBlockingHit)
{
// 在轨迹末尾找到伪造的 “影响”
Impact.Location = EndTrace;
Impact.ImpactPoint = EndTrace;
}
OutHits.Add(Impact);
}
}
}
这里面有两个细节需要看看
这条随机的 子弹发射向量是如何生成的
FVector VRandConeNormalDistribution(const FVector& Dir, const float ConeHalfAngleRad, const float Exponent)
{
// ConeHalfAngleRad = 这个手枪是7.5 除一半进来。3.75弧度。大概比PI大一点,
if (ConeHalfAngleRad > 0.f)
{
const float ConeHalfAngleDegrees = FMath::RadiansToDegrees(ConeHalfAngleRad);
// 1. 偏离中心的程度 (0.0 ~ 1.0)
// FMath::FRand() 生成 [0, 1] 之间的随机数
// Pow(Random, Exponent):
// 如果 Exponent 是 2,那么 0.5^2 = 0.25。也就是原本 50% 概率出现的值,现在变成了 25% 的位置。
// 这意味着生成的随机数经过 Pow 计算后,会变得更小(更接近 0,即更接近中心)。
const float FromCenter = FMath::Pow(FMath::FRand(), Exponent);
// 2. 将偏离程度映射到角度
// 如果 FromCenter 是 0.1,那么偏离角度就是最大半角的 10%。
const float AngleFromCenter = FromCenter * ConeHalfAngleDegrees;
// 3. 绕中心旋转的角度 (0 ~ 360度)
// 决定子弹是偏左、偏右、偏上还是偏下。这部分是完全均匀随机的。
const float AngleAround = FMath::FRand() * 360.0f;
// 4. 构建旋转四元数
// 一条Dir的向量
FRotator Rot = Dir.Rotation();
FQuat DirQuat(Rot);
// DirQuat 绕 Yaw 旋转 AngleFromCenter 度
FQuat FromCenterQuat(FRotator(0.0f, AngleFromCenter, 0.0f));
// DirQuat 绕 Roll 旋转 AngleAround 度
// 这一步非常有迷惑性。它是在转动整个坐标系。
// 想象你刚才把针往右拨歪了。现在,你捏着这根针的根部(圆心),把它沿着圆周转一个随机角度(0~360度
FQuat AroundQuat(FRotator(0.0f, 0.0, AngleAround));
// 两个四元数相乘 A * B,意思是:先进行 B 旋转,再进行 A 旋转(或者理解为在 A 的基础上叠加 B 的旋转)。
// 先绕 Yaw 旋转,再绕 Roll 旋转,最后应用到 DirQuat 上
FQuat FinalDirectionQuat = DirQuat * AroundQuat * FromCenterQuat;
FinalDirectionQuat.Normalize();
return FinalDirectionQuat.RotateVector(FVector::ForwardVector);
}
else
{
return Dir.GetSafeNormal();
}
}
另一个是 DoSingleBulletTrace 的单次射线检测
提前吱一声
FindFirstPawnHitResult 是在一排命中检测中找到一个命中的Pawn
WeaponTrace 会根据 SweepRadius 选择发射射线检测还是,圆柱检测
FHitResult UEqZeroGameplayAbility_RangedWeapon::DoSingleBulletTrace(const FVector& StartTrace, const FVector& EndTrace, float SweepRadius, bool bIsSimulated, OUT TArray<FHitResult>& OutHits) const
{
#if ENABLE_DRAW_DEBUG
if (EqZeroConsoleVariables::DrawBulletTracesDuration > 0.0f)
{
static float DebugThickness = 1.0f;
DrawDebugLine(GetWorld(), StartTrace, EndTrace, FColor::Red, false, EqZeroConsoleVariables::DrawBulletTracesDuration, 0, DebugThickness);
}
#endif // ENABLE_DRAW_DEBUG
/*
* 来自AI的比喻。。。
*
* 先试着用针扎(Fine Trace)。
扎到了人?-> 好,这一枪中了!结算!
扎到了墙?-> 记录下来打中了墙,然后继续看下一步。
啥都没扎到?-> 继续看下一步。
如果没扎到人,试着用棍子捅(Sweep Trace)。
棍子捅到了人?-> 等一下,先别急着结算!
最终审核(Validation)。
虽然棍子捅到了人,但刚才那根针是不是扎在了一堵墙上?
如果针扎在墙上,而且这堵墙就在你要捅的人前面 -> 不论棍子有没有蹭到人,都算打在墙上(防止穿墙/隔山打牛)。
如果针没扎到墙,或者是空气,或者墙在人后面 -> 判定宽容命中生效,算你打中了!
*/
FHitResult Impact;
// 如果有物体被命中,则追踪并处理即时命中
// 首先不使用扫描半径进行追踪
if (FindFirstPawnHitResult(OutHits) == INDEX_NONE)
{
// 发射一条 SweepRadius 0 的线
Impact = WeaponTrace(StartTrace, EndTrace, 0.0f, bIsSimulated, OutHits);
}
if (FindFirstPawnHitResult(OutHits) == INDEX_NONE)
{
// 没打中,提升手感,发射一个圆柱的检测
if (SweepRadius > 0.0f)
{
TArray<FHitResult> SweepHits;
Impact = WeaponTrace(StartTrace, EndTrace, SweepRadius, bIsSimulated, SweepHits);
// 如果启用了扫描半径的轨迹命中了一个 pawn,检查是否应该使用其命中结果
const int32 FirstPawnIdx = FindFirstPawnHitResult(SweepHits);
if (SweepHits.IsValidIndex(FirstPawnIdx))
{
// 官方注释:
// 如果我们的线追踪中存在阻塞命中,且该命中发生在扫描命中(SweepHits)中、
// 在我们命中 pawn 之前,那么我们应该直接使用初始的命中结果,因为 pawn 的命中应该会被阻塞。
// 检查在“打中这个人”之前,细线有没有打中墙?
// 注意:这里的 OutHits 其实是第一步 LineTrace 的结果
bool bUseSweepHits = true;
for (int32 Idx = 0; Idx < FirstPawnIdx; ++Idx)
{
const FHitResult& CurHitResult = SweepHits[Idx];
auto Pred = [&CurHitResult](const FHitResult& Other)
{
return Other.HitObjectHandle == CurHitResult.HitObjectHandle;
};
if (CurHitResult.bBlockingHit && OutHits.ContainsByPredicate(Pred))
{
bUseSweepHits = false;
break;
}
}
if (bUseSweepHits)
{
OutHits = SweepHits;
}
}
}
}
return Impact;
}