LyraLog17 装备系统 1

规划一下。

/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;
}
上一篇
下一篇