攻击传递(Attack Transmission)—— 让前排敌人撞穿后排的连锁机制
攻击传递(Attack Transmission)—— 让前排敌人撞穿后排的连锁机制
ARPG 战斗里有一种很常见的"群战手感"问题:一刀劈到第一排敌人时,玩家会期待后面那一排也吃到一点伤害或趔趄,否则总感觉刀风没穿透。这篇笔记整理一个数据驱动的"攻击传递"机制 TDD,把它从需求、设计、实现一路记下来。
1. 概述
攻击传递:当一发近战攻击直接命中敌人时,会在每个被命中敌人沿 Player→受击者方向的前方生成一个盒形检测区域(沿该方向定向,尺寸取自被命中敌人的胶囊体并乘以可配缩放系数);处于该区域内、但未被玩家直接命中的敌人,也会受到一份与直接命中完全一致的伤害与趔趄效果(复用本攻击自身的配置)。
表现上模拟"前排敌人被打飞 / 趔趄后撞到身后敌人"的连锁反应。机制完全数据驱动,只需在 USpellProductAsset 上勾选开关并设置区域参数即可启用——传递伤害直接复用该攻击资产本身,无需另配。连锁仅扩散一层。仅近战攻击生效(由武器的 Weapon.AttackRange.Melee 标签判定)。
2. 需求清单
| 编号 | 需求 |
|---|---|
| R1 | 一发攻击命中后,在被命中敌人身后生成小范围判定区域,区域内敌人即使未被直接攻击也受到伤害(表现为被前方敌人趔趄撞击)。 |
| R2 | 每个被直接命中的敌人都各自生成一个传递检测框。 |
| R3 | 传递伤害不能作用于任何被直接命中的敌人。 |
| R4 | 检测框位置 = 被命中敌人位置 + (Player→受击者) 方向 × 距离。中心前推方向取自"攻击者指向受击者"的水平向量。后排敌人的受击/趔趄方向仍取自实际命中数据 FSpellResultParam::TraceDirection。 |
| R5 | 无人为延迟;命中当帧(帧末)触发。 |
| R6 | 提供 CVar 控制的调试可视化。 |
| R7 | 已被传递命中的敌人,在本次激活内不会被其他检测框重复影响。 |
| R8 | 传递的配置与直接命中保持一致——复用原攻击资产 FlightRingAsset。 |
| R9 | 检测区域为盒形,沿 Player→受击者方向定向;尺寸取自被命中敌人的胶囊体(X=Y=2×半径、Z=2×半高),再乘以可配缩放系数 TransmissionBoxScale。 |
| R10 | 仅近战攻击生效——由武器 Weapon.AttackRange.Melee 标签判定(GetWeaponIsMelee),远程攻击不触发。 |
| R11 | 盒参数支持全局默认(UProjectAISettings);资产用 bUseDefaultTransmissionBox(默认 true)选择读全局还是用单资产值。 |
| R12 | AISetting 提供全局总开关 bEnableAttackTransmission(默认 true);为 false 时全项目关闭。 |
关于 R5/R3 的协调:要"排除整排直接命中者",必须先知道本帧所有直接命中者。因此触发被延迟到本帧末(SetTimerForNextTick)——这不是人为延迟,而是等待本帧伤害管线把所有直接命中收集完毕后再统一触发。
关于 R8 的连锁防护:复用原攻击资产意味着传递施加的资产其 bEnableAttackTransmission 仍为真,若不加防护会逐帧无限扩散。因此在代码层面,传递施加的伤害记录会被标记 bSuppressAttackTransmission,使被传递命中的敌人不会再次触发传递——连锁严格只扩散一层。
3. 设计要点
- 触发范围以"一次激活"为单位:每个
UFlightRingSubobject(对应一次 Ring/Flight 命中过程)独立累积自己的直接命中集合,帧末统一处理。 - 每个直接命中敌人各一个检测框(R2):累积每个被命中敌人及其各自的受击方向,帧末逐个生成检测区域。
- 统一排除集(R3):所有直接命中的敌人构成同一份排除列表,应用到每一个检测框;额外排除攻击者本身。
- 仅近战生效(R10):进入
TriggerAttackTransmission后先用GetWeaponIsMelee(InWeaponDef)判定,非近战直接返回。 - 盒形定向检测(R9):检测区域为盒形,朝向 = Player→受击者方向(
ToVictimDir.ToOrientationQuat())。盒全尺寸取自被命中敌人的胶囊体:X = Y = 2×GetScaledCapsuleRadius()、Z = 2×GetScaledCapsuleHalfHeight(),再整体乘以缩放系数TransmissionBoxScale;代码内取半作为 half-extent。因UKismetSystemLibrary的盒检测为轴对齐,改用UWorld::OverlapMultiByObjectType+FCollisionShape::MakeBox实现定向盒。 - 检测框中心沿 Player→受击者方向(R4):检测区域中心 = 受击敌人位置 + (攻击者指向受击者) 方向(水平归一化)× 距离。近零时回退到原攻击受击方向。中心前推方向与下面的后排受击方向是两个独立概念。
- 复用原攻击配置(R8):传递施加给后排敌人的资产即原攻击
FlightRingAsset本身,因此伤害数值、GAS 效果完全等同直接命中;后排敌人的受击方向也设为原攻击受击方向(InHitDirection),与原攻击一致。 - 伤害归属保持原攻击者:传递伤害的
Instigator/Source仍为原攻击者(玩家),以保证仇恨、连击、怒气等归属正确。 - 跨检测框去重(R7):本次激活内维护共享的"已被传递命中"集合,多个检测框重叠时同一敌人只被传递命中一次。
- 防止无限连锁:代码层防护——传递施加的伤害记录标记
bSuppressAttackTransmission,被传递命中的敌人不会再次触发传递。 - 复用现有伤害管线:检测命中后复用
UGlobalStructAndFunc::AddFightRecordToActor,走完整的伤害 + 趔趄流程。
4. 数据配置
配置分两处:全局默认(UProjectAISettings)与单资产(USpellProductAsset)。资产可通过 bUseDefaultTransmissionBox 选择读取全局默认还是用自身的盒参数。
4.1 全局默认(UProjectAISettings)
// Global master switch — when false, attack transmission is disabled project-wide.
UPROPERTY(config, EditAnywhere, Category = "Attack Transmission")
bool bEnableAttackTransmission = true;
UPROPERTY(config, EditAnywhere, Category = "Attack Transmission")
float DefaultTransmissionBehindDistance = 80.f;
// The box size is derived from the struck enemy's capsule (X = Y = 2*radius, Z = 2*half-height);
// this scale multiplies it.
UPROPERTY(config, EditAnywhere, Category = "Attack Transmission", meta = (ClampMin = "0.0"))
float DefaultTransmissionBoxScale = 1.0f;
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
bEnableAttackTransmission | bool | true | 全局总开关 |
DefaultTransmissionBehindDistance | float | 80.f | 全局默认前推距离(cm) |
DefaultTransmissionBoxScale | float | 1.0f | 全局默认缩放系数 |
4.2 单资产(USpellProductAsset)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack Transmission")
bool bEnableAttackTransmission = true;
// When true, box config is read from global default in AISetting instead of per-asset values below.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack Transmission",
meta = (EditCondition = "bEnableAttackTransmission", EditConditionHides))
bool bUseDefaultTransmissionBox = true;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack Transmission",
meta = (EditCondition = "bEnableAttackTransmission && !bUseDefaultTransmissionBox",
EditConditionHides))
float TransmissionBehindDistance = 80.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack Transmission",
meta = (EditCondition = "bEnableAttackTransmission && !bUseDefaultTransmissionBox",
ClampMin = "0.0"))
float TransmissionBoxScale = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack Transmission",
meta = (EditCondition = "bEnableAttackTransmission", EditConditionHides))
int32 TransmissionMaxTargets = 0;
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
bEnableAttackTransmission | bool | true | 总开关 |
bUseDefaultTransmissionBox | bool | true | 为真则盒参数读全局;为假则用下面两个单资产值 |
TransmissionBehindDistance | float | 80.f | 单资产前推距离 |
TransmissionBoxScale | float | 1.0f | 单资产缩放系数 |
TransmissionMaxTargets | int32 | 0 | 单个检测框最多影响的敌人数;0 = 不限 |
5. 实现细节
涉及文件:
| 文件 | 改动 |
|---|---|
ProjectAISettings.h | 新增全局默认参数 DefaultTransmissionBehindDistance / DefaultTransmissionBoxScale |
SpellProductAsset.h | 新增配置字段(开关 + bUseDefaultTransmissionBox + 单资产前推距离 + 缩放系数 + 限量) |
GlobalStructAndFunc.h/.cpp | 新增 TriggerAttackTransmission(含去重集参数、近战门槛、按 bUseDefaultTransmissionBox 解析距离/缩放、盒尺寸取自胶囊体);AddFightRecordToActor 新增 bSuppressAttackTransmission 参数 |
FlightRingSubobject.h/.cpp | 累积本次激活的所有直接命中敌人及其受击方向,帧末逐个调用 TriggerAttackTransmission |
5.1 FlightRingSubobject —— 累积与帧末触发
成员状态:
// 是否已注册帧末回调(每次激活只注册一次)
UPROPERTY()
bool bTransmissionScheduled = false;
// 本次激活所有直接命中的敌人(既是检测框圆心也是排除集)
UPROPERTY()
TArray<TObjectPtr<AActor>> TransmissionDirectHits;
// 与上一数组按索引对齐:每个敌人各自的入射方向
TArray<FVector> TransmissionHitDirs;
// 设为 true 时本 subobject 的命中不再触发传递(用于切断连锁)
UPROPERTY()
bool bSuppressAttackTransmission = false;
TransmissionDirectHits 与 TransmissionHitDirs 为按索引对齐的并行数组。bSuppressAttackTransmission 由 Setup 传入,用于连锁防护。
命中钩子(在 AddRecordToActor 内,仅对敌人累积):
// Short-circuit on the AISetting global switch — globally-disabled feature costs nothing here.
// Null settings = treat as enabled.
const UProjectAISettings* AISettings = UProjectAISettings::GetAISettings();
const bool bGlobalTransmissionEnabled = (!AISettings || AISettings->bEnableAttackTransmission);
if (bGlobalTransmissionEnabled
&& FlightRingAsset
&& FlightRingAsset->bEnableAttackTransmission
&& !bSuppressAttackTransmission)
{
const AGameCharacter* HitChar = Cast<AGameCharacter>(InTargetActor);
if (HitChar && !HitChar->IsPlayer)
{
// Push the direction only on this enemy's first record, keep arrays index-aligned
const int32 PrevNum = TransmissionDirectHits.Num();
const int32 Index = TransmissionDirectHits.AddUnique(InTargetActor);
if (Index == PrevNum)
{
TransmissionHitDirs.Add(InFightRecord->spellResultParam.TraceDirection);
}
if (!bTransmissionScheduled) // Register end-of-frame callback only once
{
bTransmissionScheduled = true;
if (UWorld* World = InTargetActor->GetWorld())
{
TWeakObjectPtr<UFlightRingSubobject> WeakSelf(this);
World->GetTimerManager().SetTimerForNextTick(
FTimerDelegate::CreateLambda([WeakSelf]()
{
if (UFlightRingSubobject* Self = WeakSelf.Get())
{
Self->FireDeferredAttackTransmission();
}
}));
}
}
}
}
帧末触发:
void UFlightRingSubobject::FireDeferredAttackTransmission()
{
if (!bTransmissionScheduled) return;
bTransmissionScheduled = false;
if (MasterActor && FlightRingAsset)
{
// Unified exclude set: all directly-hit enemies
TArray<AActor*> Excludes;
Excludes.Reserve(TransmissionDirectHits.Num());
for (const TObjectPtr<AActor>& DirectHit : TransmissionDirectHits)
{
if (DirectHit) Excludes.Add(DirectHit);
}
// Shared "already transmitted-to" set for this activation (R7)
TArray<AActor*> AffectedByTransmission;
// One detection box per hit enemy, oriented along that enemy's own hit direction
const int32 Count = TransmissionDirectHits.Num();
for (int32 i = 0; i < Count; ++i)
{
AActor* Victim = TransmissionDirectHits[i];
if (!Victim) continue;
const FVector HitDir = TransmissionHitDirs.IsValidIndex(i)
? TransmissionHitDirs[i] : FVector::ZeroVector;
UGlobalStructAndFunc::TriggerAttackTransmission(
MasterActor, Victim, SourceWeaponDef, AbilitySpecHandle,
FlightRingAsset, HitDir, Excludes, AffectedByTransmission);
}
}
TransmissionDirectHits.Reset();
TransmissionHitDirs.Reset();
}
Setup() 中重置上述状态(subobject 由对象池复用):
bTransmissionScheduled = false;
bSuppressAttackTransmission = bInSuppressAttackTransmission;
TransmissionDirectHits.Reset();
TransmissionHitDirs.Reset();
5.2 TriggerAttackTransmission —— 单敌人检测与施加
签名:
UFUNCTION(BlueprintCallable, Category = "Spell",
meta = (AutoCreateRefTerm = "InExcludeActors"))
static bool TriggerAttackTransmission(
AActor* InAttacker, AActor* InVictim,
TSubclassOf<UInventoryItemDefinition> InWeaponDef,
const FGameplayAbilitySpecHandle& InAbilitySpecHandle,
USpellProductAsset* InSourceAsset,
const FVector& InHitDirection,
const TArray<AActor*>& InExcludeActors,
UPARAM(ref) TArray<AActor*>& InOutAffectedActors);
InSourceAsset即原攻击资产FlightRingAsset,其伤害/效果直接复用(R8);InOutAffectedActors为本次激活的"已被传递命中"集合,跨多个检测框去重(R7)。
核心流程:
- 校验:
InAttacker / InVictim / InSourceAsset有效、单资产开关为真、AISetting 全局开关为真、GetWeaponIsMelee(InWeaponDef)为真(仅近战)、InVictim为敌人(AGameCharacter && !IsPlayer)。任一不满足返回 false。 - 受击方向:
HitDir = InHitDirection水平归一化;为零时回退到攻击者→受击者水平方向;再为零回退到受击者背向。 - 中心前推方向与中心点:
ToVictimDir = (Victim->GetActorLocation() - Attacker->GetActorLocation())水平归一化(近零回退到 HitDir);Center = Victim->GetActorLocation() + ToVictimDir * TransmissionBehindDistance。 - 盒参数解析与朝向尺寸:
bUseDefaultTransmissionBox为真时,BehindDistance/BoxScale取自UProjectAISettings;否则取自单资产字段。盒全尺寸取自胶囊体:BoxExtent = FVector(2×radius, 2×radius, 2×halfHeight) × BoxScale;BoxRot = ToVictimDir.ToOrientationQuat();BoxHalfExtent = BoxExtent * 0.5。 - 排除集:
ActorsToIgnore = InExcludeActors,再AddUnique(Victim)、AddUnique(Attacker),并并入InOutAffectedActors。 - 区域检测:因 Kismet 盒检测为轴对齐,改用
UWorld::OverlapMultiByObjectType(ECC_Pawn+FCollisionShape::MakeBox+ 旋转 +QueryParams.AddIgnoredActors)。 - 遍历命中:跳过玩家、跳过已死亡、跳过已在
InOutAffectedActors中的敌人;构造FHitResult使撞击方向 = 原攻击受击方向 HitDir;调用AddFightRecordToActor(..., bSuppressAttackTransmission=true)复用原资产并切断连锁;命中后InOutAffectedActors.AddUnique(Enemy)。 - 限量:
TransmissionMaxTargets > 0时达到上限即停止。
5.3 连锁防护 —— bSuppressAttackTransmission
由于传递复用原攻击资产(其 bEnableAttackTransmission 为真),而 AddFightRecordToActor 内部会新建一个 FlightRingSubobject 并调用其 AddRecordToActor——若不加防护,被传递命中的敌人会再次进入传递累积,逐帧无限扩散。
防护链路:
AddFightRecordToActor新增末位参数bSuppressAttackTransmission(默认false),透传给FlightRingSubobject::SetupSetup将其写入成员bSuppressAttackTransmissionAddRecordToActor的传递累积钩子条件追加&& !bSuppressAttackTransmissionTriggerAttackTransmission施加传递伤害时调用AddFightRecordToActor(..., bSuppressAttackTransmission=true)
由此连锁严格只扩散一层:玩家直接命中 → 一层传递 → 被传递命中者不再触发新的传递。
6. 执行流程概览
7. 调试
控制台 CVar 开启可视化(ECVF_Cheat,定义于 GlobalStructAndFunc.cpp):
| CVar | 默认 | 说明 |
|---|---|---|
Combat.AttackTransmission.Debug | 0 | 1 开启检测区域与方向的调试绘制 |
Combat.AttackTransmission.DebugDuration | 2.0 | 调试图形持续时间(秒) |
开启后绘制:
- 绿色盒体 —— 检测区域(沿 Player→受击者方向定向)
- 黄色箭头 —— 从受击敌人指向检测中心
- 红色球体 / 连线 —— 每个被传递命中的后排敌人
- 同时输出日志(受击者、中心、半径、被命中目标名)
8. 验证步骤
- 编译相关模块。
- 配置:在某近战攻击资产(
USpellProductAsset/USpellRingAsset)上勾选bEnableAttackTransmission,并设置TransmissionBehindDistance/TransmissionBoxScale(盒尺寸自动取自受击者胶囊体,无需另配传递伤害资产)。 - 控制台执行
Combat.AttackTransmission.Debug 1。 - 实机检查:
- 一发攻击同时命中多个前排敌人 → 每个被命中敌人前方各生成一个盒形检测框(沿 Player→受击者方向)
- 被直接命中的敌人不吃传递伤害
- 后排敌人吃到的伤害/效果与直接命中一致,趔趄方向与原攻击方向一致
- 多个检测框重叠时,后排同一敌人只被传递命中一次(不叠加)
- 被传递命中的敌人不会再向更后方扩散(连锁仅一层)
- 检测中心位于
受击者位置 + (Player→受击者方向) × 距离,盒尺寸 = 受击者胶囊体尺寸 ×TransmissionBoxScale - 用远程武器攻击 → 不触发传递
9. 假设与限制
- "直接命中"以单个
FlightRingSubobject的一次激活为统计范围;不同 Ring/Flight 的命中互不合并;R7 去重与连锁防护均以该激活为边界。 - 帧末触发依赖
SetTimerForNextTick,传递判定基于本帧末的敌人位置(与命中瞬间可能有极小位移差,通常可忽略)。 - 连锁防护由代码层
bSuppressAttackTransmission保证(传递施加的伤害记录不再触发传递),不依赖资产配置,因此连锁恒为一层。 - 传递复用原攻击资产,意味着后排敌人会收到与直接命中完全相同的全部效果(含命中音效/Cue 等);若某些效果不应在传递中出现,需在资产层面权衡。
TransmissionDirectHits仅记录敌人;玩家与非角色 Actor 不参与。- 仅近战攻击触发:由
GetWeaponIsMelee判定武器Weapon.AttackRange.Melee标签;该函数在WeaponDef为空时返回true(按近战处理)。 - 定向盒检测使用
UWorld::OverlapMultiByObjectType,返回的是组件级FOverlapResult,同一 Actor 多组件由InOutAffectedActors去重。
10. 设计回顾
这套机制最有意思的几个工程取舍:
- 延后到帧末是"被迫的延迟",不是"人为的延迟"——只有这样才能在 R3(排除整排直接命中者)与 R5(无人为延迟)之间取得一致;
- 数据驱动一切,连接锁防护也走代码而非配置——如果靠"传递资产里设
bEnableAttackTransmission=false"来止住连锁,迟早会有人忘记勾,最稳的做法是在传递施加点强制suppress=true; - 盒子尺寸不让美术填——直接读受击者的胶囊体,少一个手填字段就少一个出错点;只暴露
BoxScale这一个语义清晰的缩放系数; - 盒检测用
OverlapMultiByObjectType而非 Kismet 包装——因为后者只支持轴对齐盒,无法承载"沿 Player→受击者方向定向"。
这种"一刀劈进一群"的连锁伤害,看起来是简单的"前面打飞撞后面",但要做到既不漏命中、又不无限连锁、又不重复打到同一敌人,工程上需要的细节远比想象的多。