Lyra Weapons 模块深度分析------从装备实例到弹道散布的完整武器系统
写在前面:一个好的武器系统到底是什么样的?
做过多人在线射击项目的朋友大概都有这种感受------武器系统看着简单,不就是"按下扳机、射出一发子弹、打中了扣血"吗?但实际上手之后,各种细节扑面而来:散布怎么算才真实?不同姿势的精准度怎么平滑过渡?霰弹枪一枪打出去好几颗弹丸,各自该怎么检测?武器生成器如何在网络环境下正确同步?
这些问题单独拆开看,每一个都不算太难。但把它们有机地组合在一起,还要保证网络同步正确、性能开销可控、策划能随时调整参数而不用改代码------这就不是一件容易的事了。
Lyra 的 Weapons 模块给出了一份相当漂亮的答卷。它不是一个简单的"武器 Actor",而是一整套分层清晰、数据驱动的武器系统。从装备实例到弹道检测,从热量散布到材质伤害衰减,从客户端预测到命中标记同步------每一层都职责明确,每一环都相互解耦。
1. 先看全貌:模块整体架构
1.1 这个模块里都有什么?
Weapons 模块位于 Source/LyraGame/Weapons/,共 8 组文件,16 个源文件。按功能可以划分为以下几个层次:
Weapons/
├── LyraWeaponInstance.h/cpp # 武器实例基类(继承自装备实例)
├── LyraRangedWeaponInstance.h/cpp # 远程武器实例(热量/散布/伤害衰减)
├── LyraGameplayAbility_RangedWeapon.h/cpp # 远程武器射击能力(弹道检测/散布计算)
├── LyraWeaponStateComponent.h/cpp # 武器状态组件(命中标记/武器Tick驱动)
├── LyraWeaponSpawner.h/cpp # 武器生成器(世界中的可拾取武器)
├── LyraWeaponDebugSettings.h/cpp # 武器调试设置(编辑器CVar可视化)
├── LyraDamageLogDebuggerComponent.h/cpp # 伤害日志调试器(DPS统计)
└── InventoryFragment_ReticleConfig.h/cpp # 准星配置片段(Inventory Fragment)
一眼看过去,这个模块的职责拆分非常干净------不是把所有逻辑堆在一个巨大的 Weapon 类里,而是按照"数据层 → 能力层 → 表现层 → 世界交互层"的维度切开。
1.2 核心类关系图
弄清楚各类之间的继承和引用关系,是理解整个模块的第一步:
┌─────────────────────────────────────────────────────────────┐
│ ULyraEquipmentInstance (装备模块) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GetPawn() / GetInstigator() │ │
│ │ OnEquipped() / OnUnequipped() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 继承
▼
┌─────────────────────────────────────────────────────────────┐
│ ULyraWeaponInstance (武器实例基类) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 动画层选择: EquippedAnimSet / UneuippedAnimSet │ │
│ │ 输入设备属性: ApplicableDeviceProperties │ │
│ │ 射击计时: TimeLastEquipped / TimeLastFired │ │
│ │ 生命周期: OnEquipped() / OnUnequipped() │ │
│ │ 死亡处理: OnDeathStarted() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 继承 + 实现接口
▼
┌─────────────────────────────────────────────────────────────┐
│ ULyraRangedWeaponInstance (远程武器实例) │
│ implements ILyraAbilitySourceInterface │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 【散布系统】 │ │
│ │ HeatToSpreadCurve 热量→散布角度 │ │
│ │ HeatToHeatPerShotCurve 热量→每发增热 │ │
│ │ HeatToCoolDownPerSecondCurve 热量→冷却速率 │ │
│ │ SpreadExponent 散布聚集指数 │ │
│ │ SpreadRecoveryCooldownDelay 冷却启动延迟 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 【玩家状态倍率】 │ │
│ │ SpreadAngleMultiplier_Aiming 瞄准倍率 │ │
│ │ SpreadAngleMultiplier_StandingStill 站立静止倍率│ │
│ │ SpreadAngleMultiplier_Crouching 蹲姿倍率 │ │
│ │ SpreadAngleMultiplier_JumpingOrFalling 跳跃倍率│ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 【武器配置】 │ │
│ │ BulletsPerCartridge / MaxDamageRange │ │
│ │ BulletTraceSweepRadius / DistanceDamageFalloff │ │
│ │ MaterialDamageMultiplier │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ Tick() / AddSpread() / UpdateSpread() │ │
│ │ UpdateMultipliers() / ComputeHeatRange() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 被引用
▼
┌─────────────────────────────────────────────────────────────┐
│ ULyraGameplayAbility_RangedWeapon (射击能力) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 【瞄准源枚举】ELyraAbilityTargetingSource │ │
│ │ CameraTowardsFocus / PawnForward / PawnTowardsFocus│ │
│ │ WeaponForward / WeaponTowardsFocus / Custom │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 【弹道检测】 │ │
│ │ WeaponTrace() / DoSingleBulletTrace() │ │
│ │ TraceBulletsInCartridge() / PerformLocalTargeting()│ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 【能力生命周期】 │ │
│ │ CanActivateAbility() / ActivateAbility() │ │
│ │ EndAbility() / OnTargetDataReadyCallback() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 驱动
▼
┌─────────────────────────────────────────────────────────────┐
│ ULyraWeaponStateComponent (武器状态组件) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 每帧 Tick 远程武器实例 │ │
│ │ 管理命中标记: FLyraScreenSpaceHitLocation │ │
│ │ 服务端命中确认: ClientConfirmTargetData() │ │
│ │ 团队伤害判断: ShouldShowHitAsSuccess() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
一眼看下来,这个继承层次并不深------只有两层(LyraEquipmentInstance → LyraWeaponInstance → LyraRangedWeaponInstance),但每层增加的功能都很聚焦。基类管装备生命周期和通用表现,子类管远程武器特有的散布和伤害计算,再通过 GAS 能力系统把射击行为串起来。
1.3 设计哲学:数据驱动 + 分层解耦
在这个模块里,有几个反复出现的设计思路值得特别关注:
第一,一切可变参数都用曲线配置。 散布角度不是写死在代码里的一个常数,而是一条 FRuntimeFloatCurve。热量增长速率、冷却速率、距离伤害衰减------全都是曲线。策划在编辑器里拖拖点点就能完成武器手感调校,不用动一行 C++。
第二,武器数据和武器行为彻底分离。 ULyraRangedWeaponInstance 只存数据和 Tick 更新逻辑,具体的射击检测、命中处理放在 ULyraGameplayAbility_RangedWeapon 这个 GAS 能力里。数据变了不影响行为,行为改了不污染数据。
第三,状态组件充当"胶水层"。 ULyraWeaponStateComponent 挂在 Controller 上,负责每帧 Tick 武器实例、管理命中标记的客户端-服务端同步。它不直接参与射击逻辑,但把散布冷却、命中反馈这些横切关注点统一调度起来。
2. 逐层拆解:每个类的职责与实现
2.1 ULyraWeaponInstance------武器实例的"基座"
武器实例继承自 ULyraEquipmentInstance,这意味着它天然融入了 Lyra 的装备系统------装备管理器负责创建和销毁武器实例,武器实例只需要关心自己"装备上之后要做什么"和"卸下来之前要清理什么"。
2.1.1 装备与卸装备的生命周期
cpp
void ULyraWeaponInstance::OnEquipped()
{
Super::OnEquipped();
UWorld* World = GetWorld();
check(World);
TimeLastEquipped = World->GetTimeSeconds();
ApplyDeviceProperties();
}
void ULyraWeaponInstance::OnUnequipped()
{
Super::OnUnequipped();
RemoveDeviceProperties();
}
逻辑非常直白:装备时记录时间戳、应用设备属性(手柄震动等)。卸装备时移除设备属性。没有多余的操作,基类只做最通用的事情。
这里有一个容易被忽略的细节:构造函数里绑定了死亡事件。
cpp
ULyraWeaponInstance::ULyraWeaponInstance(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
if (APawn* Pawn = GetPawn())
{
if (Pawn->IsPlayerControlled())
{
if (ULyraHealthComponent* HealthComponent = ULyraHealthComponent::FindHealthComponent(GetPawn()))
{
HealthComponent->OnDeathStarted.AddDynamic(this, &ThisClass::OnDeathStarted);
}
}
}
}
为什么要在构造函数里做这个?设想这样一个场景:玩家拿着武器时被击杀了,没有机会走正常的"卸装备"流程,手柄还在震。这个死亡回调就是兜底用的------OnDeathStarted 会调用 RemoveDeviceProperties(),确保无论以什么方式结束,设备属性都能被正确清理。
2.1.2 输入设备属性------手柄震动的正确玩法
cpp
void ULyraWeaponInstance::ApplyDeviceProperties()
{
const FPlatformUserId UserId = GetOwningUserId();
if (UserId.IsValid())
{
if (UInputDeviceSubsystem* InputDeviceSubsystem = UInputDeviceSubsystem::Get())
{
for (TObjectPtr<UInputDeviceProperty>& DeviceProp : ApplicableDeviceProperties)
{
FActivateDevicePropertyParams Params = {};
Params.UserId = UserId;
Params.bLooping = true;
DevicePropertyHandles.Emplace(
InputDeviceSubsystem->ActivateDeviceProperty(DeviceProp, Params));
}
}
}
}
注意 bLooping = true 这个参数。这意味着设备属性(比如手柄扳机阻力、持续震动)会在武器持有的整个期间一直播放,而不是播一次就停。DevicePropertyHandles 这个 TSet 存着所有已激活属性的句柄,卸装备时统一调用 RemoveDevicePropertyHandles 清理。
这种"申请 → 持有句柄 → 批量释放"的模式在 UE5 的输入设备子系统中是一个推荐做法。
2.1.3 动画层选择------让动画系统知道"现在拿的是什么武器"
cpp
TSubclassOf<UAnimInstance> ULyraWeaponInstance::PickBestAnimLayer(
bool bEquipped, const FGameplayTagContainer& CosmeticTags) const
{
const FLyraAnimLayerSelectionSet& SetToQuery = (bEquipped ? EquippedAnimSet : UneuippedAnimSet);
return SetToQuery.SelectBestLayer(CosmeticTags);
}
FLyraAnimLayerSelectionSet 是一个根据 GameplayTag 选择最佳动画层的工具类。装备和卸装备各有一套配置,这样在切换武器时,动画蓝图可以通过 GameplayTag 匹配到对应的动画层,而不需要硬编码动画引用。
2.2 ULyraRangedWeaponInstance------散布系统的核心
远程武器实例是整个模块中配置项最多、Tick 逻辑最复杂的类。它同时继承了 ULyraWeaponInstance 和实现了 ILyraAbilitySourceInterface 接口。
2.2.1 热量与散布:一个"打枪 → 发热 → 枪口飘 → 冷却"的闭环
这个系统的设计思路非常接近真实枪械的物理特性:
- 每开一枪,武器产生"热量" (
AddSpread()→HeatToHeatPerShotCurve) - 热量越高,散布角度越大 (
HeatToSpreadCurve) - 停止射击后,热量逐渐冷却 (
UpdateSpread()→HeatToCoolDownPerSecondCurve) - 冷却有延迟,不是停火立刻开始收束 (
SpreadRecoveryCooldownDelay)
用三条曲线控制整个循环,而不是用几个常数:
| 曲线 | X 轴含义 | Y 轴含义 | 作用 |
|---|---|---|---|
HeatToSpreadCurve |
当前热量 | 散布角度(度) | 热量越高,散布越大 |
HeatToHeatPerShotCurve |
当前热量 | 每发增加的热量 | 可以实现"越热越容易过热"的惩罚曲线 |
HeatToCoolDownPerSecondCurve |
当前热量 | 每秒冷却量 | 可以实现"越热冷却越慢"的非线性冷却 |
三条曲线的最小/最大 X 范围共同决定了 CurrentHeat 的有效区间。ComputeHeatRange() 取三条曲线的 X 范围并集:
cpp
void ULyraRangedWeaponInstance::ComputeHeatRange(float& MinHeat, float& MaxHeat)
{
float Min1, Max1, Min2, Max2, Min3, Max3;
HeatToHeatPerShotCurve.GetRichCurveConst()->GetTimeRange(Min1, Max1);
HeatToCoolDownPerSecondCurve.GetRichCurveConst()->GetTimeRange(Min2, Max2);
HeatToSpreadCurve.GetRichCurveConst()->GetTimeRange(Min3, Max3);
MinHeat = FMath::Min(FMath::Min(Min1, Min2), Min3);
MaxHeat = FMath::Max(FMath::Max(Max1, Max2), Max3);
}
这种设计的巧妙之处在于:策划只需要调三条曲线,就能定义出一种武器的全部散布特性,不需要理解任何代码。
2.2.2 装备时热量从中间值开始------一个容易被忽略的细节
cpp
void ULyraRangedWeaponInstance::OnEquipped()
{
Super::OnEquipped();
float MinHeatRange, MaxHeatRange;
ComputeHeatRange(MinHeatRange, MaxHeatRange);
CurrentHeat = (MinHeatRange + MaxHeatRange) * 0.5f; // 从中间开始!
CurrentSpreadAngle = HeatToSpreadCurve.GetRichCurveConst()->Eval(CurrentHeat);
// 所有倍率初始化为 1x
CurrentSpreadAngleMultiplier = 1.0f;
StandingStillMultiplier = 1.0f;
JumpFallMultiplier = 1.0f;
CrouchingMultiplier = 1.0f;
}
为什么不让热量从 0 开始?设想一下:如果热量从 0 开始,那玩家每次切枪出来第一发都是完美精准的。这不符合"刚刚掏枪还在调整握姿"的直觉。从中间值起步,意味着切换武器后需要短暂冷却才能达到最佳精度------这个细节对射击手感的影响其实相当微妙但真实。
2.2.3 玩家状态倍率:站着、蹲着、跳着、瞄着,枪口表现各不相同
cpp
bool ULyraRangedWeaponInstance::UpdateMultipliers(float DeltaSeconds)
{
// 站立静止倍率:根据移动速度在 [SpreadAngleMultiplier_StandingStill, 1.0] 之间插值
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 bIsCrouching = CharMovementComp && CharMovementComp->IsCrouching();
const float CrouchingTargetValue = bIsCrouching ? SpreadAngleMultiplier_Crouching : 1.0f;
CrouchingMultiplier = FMath::FInterpTo(CrouchingMultiplier, CrouchingTargetValue,
DeltaSeconds, TransitionRate_Crouching);
// 跳跃/下落倍率
const bool bIsJumpingOrFalling = CharMovementComp && CharMovementComp->IsFalling();
const float JumpFallTargetValue = bIsJumpingOrFalling ? SpreadAngleMultiplier_JumpingOrFalling : 1.0f;
JumpFallMultiplier = FMath::FInterpTo(JumpFallMultiplier, JumpFallTargetValue,
DeltaSeconds, TransitionRate_JumpingOrFalling);
// 瞄准倍率(通过摄像机混合权重计算)
float AimingAlpha = 0.0f;
if (const ULyraCameraComponent* CameraComponent = ULyraCameraComponent::FindCameraComponent(Pawn))
{
float TopCameraWeight;
FGameplayTag TopCameraTag;
CameraComponent->GetBlendInfo(TopCameraWeight, TopCameraTag);
AimingAlpha = (TopCameraTag == TAG_Lyra_Weapon_SteadyAimingCamera) ? TopCameraWeight : 0.0f;
}
const float AimingMultiplier = FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 1.0f), FVector2D(1.0f, SpreadAngleMultiplier_Aiming), AimingAlpha);
// 综合倍率 = 所有分项倍率相乘
CurrentSpreadAngleMultiplier = AimingMultiplier * StandingStillMultiplier
* CrouchingMultiplier * JumpFallMultiplier;
// ...
}
这里有三个值得注意的设计点:
-
所有状态变化都用了
FInterpTo平滑过渡。 玩家从跑步到急停,散布不会"啪"地一下从 2.0x 跳到 0.8x,而是通过TransitionRate_StandingStill控制过渡速度。这是射击手感"扎实"与否的关键细节。 -
速度阈值不是二值判断,而是映射区间。
StandingStillSpeedThreshold和StandingStillToMovingSpeedRange定义了一个速度区间[80cm/s, 100cm/s],在这个区间内散布倍率从SpreadAngleMultiplier_StandingStill线性过渡到1.0。这样慢走和快跑的散布过渡是渐进的,不会出现"差 1cm/s 散布突然变了"的尴尬。 -
瞄准倍率通过摄像机混合系统获取。 不是简单地判断"是否按了右键",而是从
ULyraCameraComponent获取当前 Top Camera 的权重。这意味着如果项目以后想做一个"半按扳机进入精确瞄准"的机制,只需要调整摄像机混合,散布系统会自动跟随。
2.2.4 首发射击精准度:什么时候才能指哪打哪?
cpp
void ULyraRangedWeaponInstance::Tick(float DeltaSeconds)
{
APawn* Pawn = GetPawn();
check(Pawn != nullptr);
const bool bMinSpread = UpdateSpread(DeltaSeconds);
const bool bMinMultipliers = UpdateMultipliers(DeltaSeconds);
bHasFirstShotAccuracy = bAllowFirstShotAccuracy && bMinMultipliers && bMinSpread;
}
首发射击精准度的条件是三个条件同时满足:
bAllowFirstShotAccuracy------武器配置允许(策划开关)bMinMultipliers------所有玩家状态倍率都处于最小值(站定、不跳、蹲稳、瞄好)bMinSpread------武器热量已降到最低(停火足够久)
当 bHasFirstShotAccuracy 为 true 时,GetCalculatedSpreadAngleMultiplier() 返回 0.0f,意味着这一发子弹的散布角度被完全归零------真正的指哪打哪。
2.2.5 ILyraAbilitySourceInterface------武器作为伤害计算的"数据源"
远程武器实例实现了 ILyraAbilitySourceInterface 接口,提供两个关键的伤害衰减计算:
cpp
float ULyraRangedWeaponInstance::GetDistanceAttenuation(float Distance, ...) const
{
const FRichCurve* Curve = DistanceDamageFalloff.GetRichCurveConst();
return Curve->HasAnyData() ? Curve->Eval(Distance) : 1.0f;
}
float ULyraRangedWeaponInstance::GetPhysicalMaterialAttenuation(
const UPhysicalMaterial* PhysicalMaterial, ...) 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;
}
距离衰减很简单:从 DistanceDamageFalloff 曲线取 Y 值。如果曲线里没有任何数据,返回 1.0f(无衰减)。
材质衰减的逻辑也很直接:命中点的 UPhysicalMaterialWithTags 携带了一组 GameplayTag(比如 Surface.Flesh.Head),在 MaterialDamageMultiplier 这个 TMap<FGameplayTag, float> 中查找对应倍率,多个匹配的标签倍率相乘。这样策划可以定义"头部 x2.0,护甲 x0.5"等规则,同样不需要改代码。
2.3 ULyraGameplayAbility_RangedWeapon------射击行为的执行者
射击能力继承自 ULyraGameplayAbility_FromEquipment(这也是 Lyra 自己的封装),自动获得了"与装备实例关联"的能力。它的核心工作流程可以概括为:获取瞄准方向和散布数据 → 计算每条弹道的随机偏转 → 执行碰撞检测 → 组装 TargetData → 提交给 GAS 处理伤害。
2.3.1 激活时绑定 TargetData 回调
cpp
void ULyraGameplayAbility_RangedWeapon::ActivateAbility(...)
{
UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
check(MyAbilityComponent);
OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(
CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey())
.AddUObject(this, &ThisClass::OnTargetDataReadyCallback);
ULyraRangedWeaponInstance* WeaponData = GetWeaponInstance();
check(WeaponData);
WeaponData->UpdateFiringTime();
Super::ActivateAbility(...);
}
这里的关键操作是 AbilityTargetDataSetDelegate------它注册了一个回调,当 GAS 的 TargetData 被服务端确认后会触发。UpdateFiringTime() 记录了"上一次开枪时间",是散布冷却计算的基准点。
激活能力本身并不 直接执行射击检测------射击检测是由蓝图调用 StartRangedWeaponTargeting() 触发的。这层间接性给蓝图逻辑留出了空间(比如播放开火动画后再执行检测)。
2.3.2 StartRangedWeaponTargeting------从瞄准到命中标记的完整流水线
cpp
void ULyraGameplayAbility_RangedWeapon::StartRangedWeaponTargeting()
{
// 1. 获取 WeaponStateComponent(用于命中标记)
AController* Controller = GetControllerFromActorInfo();
ULyraWeaponStateComponent* WeaponStateComponent =
Controller->FindComponentByClass<ULyraWeaponStateComponent>();
// 2. 执行本地弹道检测
TArray<FHitResult> FoundHits;
PerformLocalTargeting(FoundHits);
// 3. 组装 TargetData
FGameplayAbilityTargetDataHandle TargetData;
TargetData.UniqueId = WeaponStateComponent
? WeaponStateComponent->GetUnconfirmedServerSideHitMarkerCount() : 0;
for (const FHitResult& FoundHit : FoundHits)
{
FLyraGameplayAbilityTargetData_SingleTargetHit* NewTargetData =
new FLyraGameplayAbilityTargetData_SingleTargetHit();
NewTargetData->HitResult = FoundHit;
NewTargetData->CartridgeID = FMath::Rand();
TargetData.Add(NewTargetData);
}
// 4. 记录未确认的命中标记(用于客户端预测显示)
if (WeaponStateComponent)
{
WeaponStateComponent->AddUnconfirmedServerSideHitMarkers(TargetData, FoundHits);
}
// 5. 提交 TargetData 回调流程
OnTargetDataReadyCallback(TargetData, FGameplayTag());
}
每一步的职责都非常明确:
PerformLocalTargeting()完成实际的物理检测,产生FHitResult列表。TargetData.UniqueId使用的是GetUnconfirmedServerSideHitMarkerCount()的当前值,这样服务端后续ClientConfirmTargetData时可以用这个 ID 来匹配和确认。AddUnconfirmedServerSideHitMarkers()把屏幕空间的命中位置记录下来,客户端立刻就能显示命中标记(不需要等服务端确认),然后在服务端确认后再筛选哪些是"有效命中"。
2.3.3 VRandConeNormalDistribution------散布圆锥的数学内核
这是整个散布系统里最"数学"的部分,也是射击手感的核心:
cpp
FVector VRandConeNormalDistribution(const FVector& Dir,
const float ConeHalfAngleRad, const float Exponent)
{
if (ConeHalfAngleRad > 0.f)
{
const float ConeHalfAngleDegrees = FMath::RadiansToDegrees(ConeHalfAngleRad);
// fromCenter 决定了离中心线多远,指数控制聚集程度
const float FromCenter = FMath::Pow(FMath::FRand(), Exponent);
const float AngleFromCenter = FromCenter * ConeHalfAngleDegrees;
const float AngleAround = FMath::FRand() * 360.0f;
FRotator Rot = Dir.Rotation();
FQuat DirQuat(Rot);
FQuat FromCenterQuat(FRotator(0.0f, AngleFromCenter, 0.0f));
FQuat AroundQuat(FRotator(0.0f, 0.0, AngleAround));
FQuat FinalDirectionQuat = DirQuat * AroundQuat * FromCenterQuat;
FinalDirectionQuat.Normalize();
return FinalDirectionQuat.RotateVector(FVector::ForwardVector);
}
else
{
return Dir.GetSafeNormal();
}
}
算法的核心在于这一行:
cpp
const float FromCenter = FMath::Pow(FMath::FRand(), Exponent);
- 当
Exponent = 1.0:FMath::Pow(rand, 1.0) = rand,均匀分布------弹孔在散布圆内均匀撒开。 - 当
Exponent > 1.0:比如Exponent = 3.0,FMath::Pow(rand, 3.0)会把大量结果压缩到接近 0 的区域,弹孔更集中在中心------这是"压枪好手"的手感。 - 当
Exponent < 1.0:弹孔更靠近散布圆的边缘------一种比较反常的散布模式,可能用于某些特殊武器。
四元数组合的应用也非常讲究:DirQuat * AroundQuat * FromCenterQuat 的乘法顺序意味着先绕中心做俯仰偏移,再绕原始方向做水平旋转,最后叠加到目标方向上。这个顺序保证了散布圆锥在几何上的正确性。
2.3.4 双阶段弹道检测------先线后球,兼顾性能与宽容度
cpp
FHitResult ULyraGameplayAbility_RangedWeapon::DoSingleBulletTrace(
const FVector& StartTrace, const FVector& EndTrace, float SweepRadius,
bool bIsSimulated, TArray<FHitResult>& OutHits) const
{
FHitResult Impact;
// 阶段一:线检测(更快)
if (FindFirstPawnHitResult(OutHits) == INDEX_NONE)
{
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);
const int32 FirstPawnIdx = FindFirstPawnHitResult(SweepHits);
if (SweepHits.IsValidIndex(FirstPawnIdx))
{
// 检查球形检测的命中是否被线检测的阻挡命中所遮挡
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;
}
这个双阶段策略的精妙之处在于:
BulletTraceSweepRadius = 0的武器(比如精确步枪)永远只用线检测,性能最优。BulletTraceSweepRadius > 0的武器(比如霰弹枪)先试线检测------如果线检测已经打到人了,就不做更昂贵的球形检测。- 即使切换到球形检测,也会检查球形检测的命中是否被线检测中已经存在的阻挡命中所遮挡。这防止了"子弹穿透掩体打到后面的人"的情况。
WeaponTrace() 内部还有一个值得注意的过滤逻辑:
cpp
// 对同一 Actor 的多重命中做去重
for (FHitResult& CurHitResult : HitResults)
{
auto Pred = [&CurHitResult](const FHitResult& Other) {
return Other.HitObjectHandle == CurHitResult.HitObjectHandle;
};
if (!OutHitResults.ContainsByPredicate(Pred))
{
OutHitResults.Add(CurHitResult);
}
}
这个过滤是因为球形检测可能在同一个 Actor 的不同部位产生多个命中结果,但一发子弹不应该对同一个人造成多次伤害。
2.3.5 伤害应用流程(OnTargetDataReadyCallback)
cpp
void ULyraGameplayAbility_RangedWeapon::OnTargetDataReadyCallback(
const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag)
{
// 1. 客户端先做预测提交
if (bShouldNotifyServer)
{
MyAbilityComponent->CallServerSetReplicatedTargetData(...);
}
// 2. 服务端确认命中标记
#if WITH_SERVER_CODE
if (Controller->GetLocalRole() == ROLE_Authority)
{
WeaponStateComponent->ClientConfirmTargetData(
LocalTargetDataHandle.UniqueId, bIsTargetDataValid, HitReplaces);
}
#endif
// 3. Commit 能力消耗并应用伤害
if (bIsTargetDataValid && CommitAbility(...))
{
WeaponData->AddSpread(); // 增加热度
OnRangedWeaponTargetDataReady(LocalTargetDataHandle); // 蓝图实现伤害应用
}
}
这里体现了 GAS 的客户端预测架构:客户端先乐观地显示命中标记(AddUnconfirmedServerSideHitMarkers),等服务端确认后再通过 ClientConfirmTargetData 修正。如果服务端认为某些命中无效(比如因为延迟导致实际上没打中),客户端会移除对应的命中标记。
2.4 ULyraWeaponStateComponent------武器系统的"后台调度员"
这个组件挂在 Controller 上,承担了两个核心职责:驱动武器每帧 Tick 和 管理命中标记的客户端-服务端同步。
2.4.1 为什么是 ControllerComponent 来 Tick 武器?
cpp
void ULyraWeaponStateComponent::TickComponent(float DeltaTime, ...)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (APawn* Pawn = GetPawn<APawn>())
{
if (ULyraEquipmentManagerComponent* EquipmentManager =
Pawn->FindComponentByClass<ULyraEquipmentManagerComponent>())
{
if (ULyraRangedWeaponInstance* CurrentWeapon =
Cast<ULyraRangedWeaponInstance>(
EquipmentManager->GetFirstInstanceOfType(
ULyraRangedWeaponInstance::StaticClass())))
{
CurrentWeapon->Tick(DeltaTime);
}
}
}
}
武器实例本身是一个 UObject,不是 UActorComponent,它自己没有 Tick 能力。让 ULyraWeaponStateComponent 来驱动,有几个好处:
- Controller 的生命周期比 Pawn 更稳定(玩家死亡重生,Controller 不变)。
- 把 Tick 集中在一个地方,方便统一管理(比如暂停游戏时关掉武器冷却)。
- 不需要在武器实例上额外挂任何组件,保持数据对象的纯粹性。
2.4.2 命中标记系统------客户端预测 + 服务端确认
命中标记的完整数据流:
客户端: 射击 → PerformLocalTargeting()
→ AddUnconfirmedServerSideHitMarkers() // 立刻显示命中标记
→ 发送 TargetData 到服务端
服务端: 验证命中有效性
→ ClientConfirmTargetData(UniqueId, bSuccess, HitReplaces)
→ 客户端收到确认后:
- 移除被服务端判定为无效的命中标记
- 保留有效的标记到 LastWeaponDamageScreenLocations
FLyraServerSideHitMarkerBatch 通过 UniqueId 来匹配客户端的预测和服务器确认:
cpp
void ULyraWeaponStateComponent::ClientConfirmTargetData_Implementation(
uint16 UniqueId, bool bSuccess, const TArray<uint8>& HitReplaces)
{
for (int i = 0; i < UnconfirmedServerSideHitMarkers.Num(); i++)
{
FLyraServerSideHitMarkerBatch& Batch = UnconfirmedServerSideHitMarkers[i];
if (Batch.UniqueId == UniqueId)
{
if (bSuccess && (HitReplaces.Num() != Batch.Markers.Num()))
{
// 筛选出未被服务端替换掉的有效命中
for (const FLyraScreenSpaceHitLocation& Entry : Batch.Markers)
{
if (!HitReplaces.Contains(HitLocationIndex) && Entry.bShowAsSuccess)
{
ActuallyUpdateDamageInstigatedTime();
LastWeaponDamageScreenLocations.Add(Entry);
}
}
}
UnconfirmedServerSideHitMarkers.RemoveAt(i);
break;
}
}
}
HitReplaces 数组记录了哪些命中索引被服务端判定为"被替换掉"(即服务端认为这个命中不应该发生,比如因为反作弊判定、延迟导致的命中失效等)。客户端收到确认后只保留那些没被替换的有效命中。
2.5 ALyraWeaponSpawner------世界中的武器拾取点
武器生成器是一个放在关卡里的 Actor,负责武器的显示、拾取、冷却重生。它有几个设计上的亮点:
2.5.1 DataAsset 驱动配置
cpp
UPROPERTY(EditInstanceOnly, BlueprintReadOnly)
TObjectPtr<ULyraWeaponPickupDefinition> WeaponDefinition;
ULyraWeaponPickupDefinition 是一个 DataAsset,包含了武器要显示什么网格体、拾取音效和特效、冷却时间、以及关联的 ULyraInventoryItemDefinition。这意味着关卡设计师只需要把这个 DataAsset 拖到 Spawner 的属性上就能配置一个武器拾取点,完全不需要动代码。
2.5.2 网络同步的武器可用状态
cpp
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, ReplicatedUsing = OnRep_WeaponAvailability)
bool bIsWeaponAvailable;
void ALyraWeaponSpawner::OnRep_WeaponAvailability()
{
if (bIsWeaponAvailable)
{
PlayRespawnEffects();
SetWeaponPickupVisibility(true);
}
else
{
SetWeaponPickupVisibility(false);
StartCoolDown();
PlayPickupEffects();
}
}
bIsWeaponAvailable 通过 ReplicatedUsing 同步------当服务端改变这个值时,所有客户端都会收到通知并播放相应的视觉效果。这种"状态同步 + OnRep 驱动表现"的模式在 UE 的网络编程中非常经典。
2.5.3 拾取逻辑的服务端权威
cpp
void ALyraWeaponSpawner::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor, ...)
{
APawn* OverlappingPawn = Cast<APawn>(OtherActor);
if (GetLocalRole() == ROLE_Authority && bIsWeaponAvailable && OverlappingPawn != nullptr)
{
AttemptPickUpWeapon(OverlappingPawn);
}
}
重叠检测只在服务端执行 (ROLE_Authority 判断)。这防止了客户端作弊------只有服务端有权决定玩家是否成功拾取了武器。
AttemptPickUpWeapon 是一个 BlueprintNativeEvent,它在 C++ 中做权限和状态检查,然后调用蓝图的 GiveWeapon 事件来实际把物品加入到玩家的背包系统中。
2.5.4 冷却重生与"已有玩家站在重生点"的处理
cpp
void ALyraWeaponSpawner::ResetCoolDown()
{
// ...
if (GetLocalRole() == ROLE_Authority)
{
bIsWeaponAvailable = true;
PlayRespawnEffects();
SetWeaponPickupVisibility(true);
// 延迟一小段时间后检查是否有玩家已经站在重生点上
World->GetTimerManager().SetTimer(CheckOverlapsDelayTimerHandle,
this, &ALyraWeaponSpawner::CheckForExistingOverlaps, CheckExistingOverlapDelay);
}
}
CheckExistingOverlapDelay(默认 0.25 秒)这个延迟非常有必要:武器刚重生时 Overlap 事件可能还没触发,如果玩家恰好站在生成点上,需要一个短暂延迟来重新检查重叠状态,确保玩家能正确拾取。
2.6 其他辅助组件
2.6.1 ULyraWeaponDebugSettings------CVar 的编辑器可视化
cpp
UCLASS(config=EditorPerProjectUserSettings)
class ULyraWeaponDebugSettings : public UDeveloperSettingsBackedByCVars
{
UPROPERTY(config, EditAnywhere, meta=(ConsoleVariable="lyra.Weapon.DrawBulletTraceDuration"))
float DrawBulletTraceDuration;
UPROPERTY(config, EditAnywhere, meta=(ConsoleVariable="lyra.Weapon.DrawBulletHitDuration"))
float DrawBulletHitDuration;
UPROPERTY(config, EditAnywhere, meta=(ConsoleVariable="lyra.Weapon.DrawBulletHitRadius"))
float DrawBulletHitRadius;
};
UDeveloperSettingsBackedByCVars 是 UE5 提供的一个很实用的基类------它允许在项目设置的编辑器界面中直接修改控制台变量的值。调试时不用每次打开控制台敲命令,在 Project Settings 里就能可视化调整子弹轨迹的绘制时长和命中点半径。
2.6.2 ULyraDamageLogDebuggerComponent------伤害统计的调试利器
这个组件通过 UGameplayMessageSubsystem 订阅伤害消息,按帧聚合伤害数据,在停火一段时间后输出 DPS 统计:
cpp
void ULyraDamageLogDebuggerComponent::TickComponent(float DeltaTime, ...)
{
const double TimeSinceDamage = GetWorld()->GetTimeSeconds() - LastDamageEntryTime;
if ((TimeSinceDamage >= SecondsBetweenDamageBeforeLogging) && (DamageLog.Num() > 0))
{
// 按时间排序所有帧的伤害记录
// 输出: 命中次数、帧数、总时间跨度、总伤害
// 输出: 最小/最大/平均帧间隔
// 输出: DPS
DamageLog.Reset();
}
}
它使用 GFrameCounter 作为键来聚合同一帧内的多次伤害,这样能精确统计"这一帧打了几发、造成多少伤害",对于平衡性调试非常有用。
2.6.3 UInventoryFragment_ReticleConfig------蓝图可配置的准星
cpp
UCLASS()
class UInventoryFragment_ReticleConfig : public ULyraInventoryItemFragment
{
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Reticle)
TArray<TSubclassOf<ULyraReticleWidgetBase>> ReticleWidgets;
};
这个类本身几乎没有逻辑------它就是 Lyra Inventory Fragment 系统的一个具体应用。把它挂到武器物品定义上,武器系统就能在运行时查询到这把武器应该显示什么准星。Fragment 模式的好处在于:不同的物品可以混搭不同的 Fragment 组合,比如一把武器可以同时有 ReticleConfig、SetStats、QuickBarIcon 等多个 Fragment,每个 Fragment 负责一个维度的配置。
3. 数据流全览------从按下扳机到屏幕上的命中标记
把各个组件串联起来,一次完整的射击流程是这样的:
玩家按下射击键
│
▼
ULyraGameplayAbility_RangedWeapon::ActivateAbility()
│ 注册 TargetData 回调,更新开火时间
▼
蓝图调用 StartRangedWeaponTargeting()
│
▼
PerformLocalTargeting()
│ 从 CameraComponent 获取瞄准方向和摄像机位置
│ 构建 FRangedWeaponFiringInput
▼
TraceBulletsInCartridge()
│ for (每发子弹):
│ 从武器实例获取: SpreadAngle, SpreadMultiplier, SpreadExponent
│ VRandConeNormalDistribution() → 随机偏转方向
│ DoSingleBulletTrace() → 线检测(→失败时球形检测)
▼
组装 FGameplayAbilityTargetDataHandle
│
├─→ WeaponStateComponent::AddUnconfirmedServerSideHitMarkers()
│ 客户端立刻显示命中标记
│
├─→ (客户端) CallServerSetReplicatedTargetData()
│ 发送 TargetData 到服务端
│
▼
OnTargetDataReadyCallback()
│
├─→ (服务端) ClientConfirmTargetData()
│ 确认命中标记有效性
│
├─→ CommitAbility() → 消耗弹药/冷却等
│
├─→ WeaponData->AddSpread()
│ 增加武器热量,影响下一发精度
│
└─→ OnRangedWeaponTargetDataReady() (BlueprintImplementableEvent)
蓝图实现具体伤害应用(调用 ApplyGameplayEffectToTarget 等)
4. 几个值得借鉴的设计模式
4.1 数据与行为分离
武器数据(ULyraRangedWeaponInstance)和射击行为(ULyraGameplayAbility_RangedWeapon)完全分离。数据对象只负责存储配置和状态更新,行为对象负责实际的物理检测和伤害提交。这种分离意味着:
- 近战武器可以复用同样的数据层,只需要换一个近战检测的 Ability。
- 数据对象可以被多个系统引用(动画系统、UI 系统),不依赖 GAS 也能读取武器配置。
4.2 曲线替代常数
散布、热量、伤害衰减------所有这些"手感参数"都用 FRuntimeFloatCurve 配置。曲线不仅提供了非线性的灵活性,更重要的是让数值策划能独立工作,不用在"改一个数值 → 编译 → 进游戏测试"的循环里浪费时间。
4.3 组件驱动的 Tick
武器实例自己没有 Tick,由 ULyraWeaponStateComponent 来驱动。这意味着 Tick 的开关、频率、暂停都由 ControllerComponent 统一控制。将来如果要加"子弹时间"效果,只需要调整组件的 Tick 间隔。
4.4 Fragment 模式的可组合配置
准星配置通过 Inventory Fragment 挂载到武器物品上,散布参数直接在武器实例的 UPROPERTY 上。两个维度的配置互不干扰,但能在运行时组合在一起。这种"横向切 Fragment + 纵向继承 Instance"的配置模式非常适合需要高度可配置性的游戏系统。
4.5 客户端预测 + 服务端权威
命中标记系统是典型的"客户端乐观预测 + 服务端权威验证"模式。客户端先显示标记以消除延迟感,服务端确认后再通过 ClientConfirmTargetData 做最终裁定。这种模式也适用于其他需要即时反馈的网络功能(比如交互提示、技能指示器)。
写在最后
Lyra 的 Weapons 模块给人的整体印象是:克制而精准。
它不是那种"把所有功能都塞进去"的巨型模块。相反,它的每一个类都只做一件事,而且做得很干净。武器实例管数据和状态,GA 管射击行为,状态组件管 Tick 和命中同步,生成器管世界交互------职责边界非常清晰。
同时也必须承认,这个模块还有一些可以进一步打磨的地方:
- 没有弹药/换弹系统。 当前的武器是"无限子弹"的,开火不需要消耗任何资源(除了增加热量)。如果需要弹匣管理,就需要在武器实例和 GA 之间加一层弹药状态管理。
- GetWeaponTargetingSourceLocation() 目前只是一个占位实现。 注释里标注了
@TODO: Add an offset from the weapon instance and adjust based on pawn crouch/aiming/etc...,实际项目中需要根据武器的枪口挂点来修正射击起点。 - 没有近战武器支持。 虽然架构上预留了扩展空间(
ULyraWeaponInstance基类不限定远程),但当前只实现了远程武器。近战武器需要全新的检测方式(近战碰撞体或锥形检测)。
几个在阅读源码时最值得记住的点:
- 热量的起点是中间值,不是零------这是射击手感的重要细节。
- 散布倍率用
FInterpTo平滑过渡,速度判断用区间映射而非二值------手感是在这些细节里磨出来的。 - 弹道检测是先线后球的二段式策略------兼顾性能与命中宽容度,值得在自己的项目里参考。
UDeveloperSettingsBackedByCVars是个容易被忽视的好工具------把调试参数搬到编辑器界面里,比每次敲控制台命令高效得多。
希望这篇文章能帮大家少走一些弯路,在搭建自己的武器系统时多一个靠谱的参照。