首页 / 资讯中心 / 文章详情

UE5 C++ ARPG角色攻击准备:武器挂载与动画通知实战解析

UE5 C++ ARPG角色攻击准备:武器挂载与动画通知实战解析 ★ FEATURED ARTICLE
ARPG项目做到角色能跑能跳之后团队里问得最多的一个问题就是攻击怎么做我每次跟组里的新同事都说同一句话——先别急着写伤害公式先把武器挂到手上、把攻击动画跑起来。UE5.3 下用 C 做 ARPG准备角色攻击的武器和动画这件事听起来只是资源摆放实际上它决定了后面整条战斗链路的地基武器挂在哪个骨骼、动画前摇多少帧、通知窗口的起点在哪这些数据没定下来后面的连招、格挡、处决全是空中楼阁。这篇文章我按自己在项目里趟过的路径来讲适合已经通过前几篇把角色移动、输入系统和基础状态机搭好的读者。你会看到武器挂载的取舍、动画资产的准备方式、C 触发攻击的完整链路以及一整个下午实测中踩出来的坑。我不打算给你一份十全大补的百页文档只讲 ARPG 攻击准备阶段真正要命的那几件事。1. 为什么攻击要先备武器、备动画而不是先写伤害1.1 攻击手感里最先被玩家感知到的不是数值如果你去问一个玩家“刚才那一刀手感怎么样”他大概率不会说“伤害数字差了 5 点”而是会说“出刀慢半拍”“刀挥过去像在打空气”“身体和武器是分开的”。这些反馈全部来自武器挂载和动画执行跟伤害计算一丁点关系都没有。ARPG 里一次标准攻击在感官上可以拆成四个阶段起手StartUp、前摇Anticipation、判定帧Active、后摇Recovery。玩家按一下攻击键系统先播起手动作身体重心变化武器随动画位移到判定帧时命中特效和停顿出现最后后摇收回。一个案件的打击感本质上就是这四个阶段的时间分配和视觉反馈是否舒服。你把数值系统写得再严谨动画出来是“光速挥刀”玩家一样会觉得这个游戏很廉价。所以我建议团队在写伤害系统之前先把武器和攻击动画跑通一版。哪怕这版伤害固定写死 10 点只要挥砍轨迹和命中反馈是对味的后续接碰撞、接敌人 AI、接连招都会有明确的参考锚点。反过来先写复杂的状态机再回头补动画大概率会陷入“动画没到位状态机不敢删代码越改越绕”的泥潭。1.2 动画与代码的接口最好在动手前就约定好这一步很多人会忽略。武器挂载看似是资源摆放但真正推动项目的是你打算让动画和代码之间怎么通信。我在项目里坚持一个原则C 代码不要关心“具体第几帧要挥刀”而是通过 Montage 的 Section、AnimNotify、动画曲线来跟动画资产通信。也就是说动画侧的准备其实是在定义一组编程接口。如果攻击动画里塞满蓝图逻辑或者代码里硬编码“等 0.2 秒开启碰撞”那动画师每改一帧动画程序就要跟着改一遍代码早晚崩溃。所以动手前先定命名规范。下面是我们项目里长期执行的一份简易约定SocketWeapon_R_Hand、Weapon_L_Hand统一挂在手部骨骼下AnimNotifyStateMeleeActiveStart、MeleeActiveEnd动画曲线HitFeedback、AttackMoveMontage 分组所有攻击类动画走同一个 Slot 组这套约定不需要写进框架文档先花十分钟在团队里形成共识即可。后续 C 端直接按这个名称去取 Socket、绑通知、读曲线动画端也清楚自己该在哪里放通知两边可以并行推进不用互相干等。2. 武器挂载把模型钉死在角色骨骼上2.1 Socket 是唯一不推荐“拍脑门坐标”的做法武器挂载第一个选择就是把武器放在哪里。我见过最蠢也最常见的做法是直接在角色根组件下挂一个子组件然后手填一个相对坐标比如(20, 40, -10)。这种方法在做站立挥砍 Demo 时勉强能用角色一动起来就原形毕露——因为骨骼会随动画旋转、位移而挂在角色根组件下的武器却纹丝不动看起来就像武器和自己的手各玩各的。正确做法是在骨骼上创建一个 Socket然后让武器组件挂到 Socket 下。UE 的 Socket 本质是骨骼上的一个“锚点”可以设置相对位置和旋转。角色播放动画时骨骼怎么动Socket 就怎么动武器自然跟着手走。在骨骼编辑器中选中手部骨骼右键 Add Socket然后调位置旋转。要注意的是Socket 的位置并不是“看着差不多就行”它得配合武器的 Pivot 点来定。我在 2.2 会展开讲 Pivot 的坑这里先说结论先把 Socket 调到手握武器的中心位置再让武器的原点对齐到这个位置这样后续换武器、换动画都不会乱。三种方案对比一下方案实现成本动画适配换武器推荐度Socket 挂载中最好骨骼带动简单强烈推荐直接根组件下填坐标低差角色转动就歪繁琐不推荐场景 Actor 手动跟随高不稳定复杂几乎不用2.2 一个干净的 AWeaponBase 怎么写ARPG 的武器我一般用一个极简 Actor 来表示核心职责就三件展示 Mesh、控制碰撞体开关、暴露攻击接口。碰撞体不直接放在角色手里而是放在武器自身这样以后换武器时攻击窗口的逻辑完全不用改。// WeaponBase.h UCLASS() class MYARPG_API AWeaponBase : public AActor { GENERATED_BODY() public: AWeaponBase(); // 角色调用把武器挂到指定骨骼 Socket 上 void EquipToHand(USkeletalMeshComponent* ReferenceMesh, const FName HandSocketName); // 攻击窗口开启 / 关闭时调用 void SetAttackCollisionEnabled(bool bEnabled); protected: UPROPERTY(VisibleAnywhere, Category Weapon) USceneComponent* RootComp; UPROPERTY(VisibleAnywhere, Category Weapon) UStaticMeshComponent* WeaponMesh; UPROPERTY(VisibleAnywhere, Category Weapon) UBoxComponent* AttackCollision; };// WeaponBase.cpp AWeaponBase::AWeaponBase() { RootComp CreateDefaultSubobjectUSceneComponent(TEXT(Root)); SetRootComponent(RootComp); WeaponMesh CreateDefaultSubobjectUStaticMeshComponent(TEXT(WeaponMesh)); WeaponMesh-SetupAttachment(RootComp); AttackCollision CreateDefaultSubobjectUBoxComponent(TEXT(AttackCollision)); AttackCollision-SetupAttachment(WeaponMesh); AttackCollision-SetGenerateOverlapEvents(true); AttackCollision-SetCollisionProfileName(TEXT(OverlapAllDynamic)); AttackCollision-SetCollisionEnabled(ECollisionEnabled::NoCollision); } void AWeaponBase::EquipToHand(USkeletalMeshComponent* ReferenceMesh, const FName HandSocketName) { if (!ReferenceMesh || !ReferenceMesh-DoesSocketExist(HandSocketName)) { UE_LOG(LogTemp, Warning, TEXT(EquipToHand: Socket %s 不存在), *HandSocketName.ToString()); return; } const FAttachmentTransformRules AttachRules( EAttachmentRule::SnapToTarget, EAttachmentRule::SnapToTarget, EAttachmentRule::KeepRelative, false); AttachToComponent(ReferenceMesh, AttachRules, HandSocketName); SetActorRelativeLocation(FVector::ZeroVector); SetActorRelativeRotation(FRotator::ZeroRotator); }这里解释一下FAttachmentTransformRules三个参数的含义前两个SnapToTarget表示位置和旋转直接和 Socket 对齐也就是武器“啪”一下贴合到握持点第三个KeepRelative表示缩放保持武器自身相对比例不继承角色的统一缩放避免出现武器忽大忽小的情况。最后一个bWeldSimulatedBodies对普通静态网格影响不大关掉就好。2.3 装备时机不要开局就在手上挂把隐形的刀新手最容易犯的错是角色BeginPlay里SpawnActor之后立刻AttachToComponent。这样武器稀疏会先在世界的某个角落生成再到角色手上玩家能看到一道白光闪烁如果角色出生点刚好和地面重叠武器还会触发一次物理碰撞弹得满场飞。我习惯的流程是“先藏起来再等时机挂载”。武器生成时隐藏并关闭碰撞等角色初始化完成、Physic 状态稳定之后再调用EquipToHand并显示。这里“时机”在单机项目里可以放在进入战斗状态那一刻也可以放在一个显式的装备接口里。// 角色侧装备流程 void ABaseCharacter::SpawnAndEquipWeapon() { if (!CurrentWeapon WeaponClass) { FActorSpawnParameters Params; Params.SpawnCollisionHandlingOverride ESpawnActorCollisionHandlingMethod::AlwaysSpawn; CurrentWeapon GetWorld()-SpawnActorAWeaponBase(WeaponClass, FVector::ZeroVector, FRotator::ZeroRotator, Params); // 先生成但隐藏避免世界内闪现 CurrentWeapon-SetActorHiddenInGame(true); CurrentWeapon-SetActorEnableCollision(false); } if (CurrentWeapon) { FName HandSocket TEXT(Weapon_R_Hand); CurrentWeapon-EquipToHand(GetMesh(), HandSocket); CurrentWeapon-SetActorHiddenInGame(false); } }装备时机定的早并不是什么炫技需求而是为了后面接伤害系统时你的武器一定处于“可控状态”。万一将来加拾取、加掉落这套隐藏、挂载、显示的接口也能复用。3. 攻击动画的三层准备Sequence、Montage 与 AnimBP3.1 Sequence 导入骨骼不对后面全是白做攻击动画的源头很多动捕、资源平台、自己 K 关键帧都行。但不管哪里来的动画第一关都是骨骼匹配。UE 的动画资产本质上是在记录“每一根骨骼在每一帧的变换”如果骨骼层级和角色 Skeleton 对不上哪怕模型看着是一个人类动画也会发生稀奇古怪的扭曲。这里我建议导入动画时做两件事。第一确认 FBX 里的骨骼映射正确特别是根骨骼。很多资源平台导出的动画会带一个额外的 Root 骨骼不处理的话角色攻击时会原地平移或漂移。第二统一命名规范比如SK_Character_Attack01、SK_Character_Attack02。动画多了以后你不可能靠肉眼一个个点开预览好的命名能让你在资产浏览器里瞬间定位到想要的动作。导入后还要看一遍关键帧确认动作的方向和角色默认朝向一致。有些动作导入后是 90 度偏转的原因是资源制作时角色面向 X 或 Y 轴不同。这种问题在动画资源里修很简单到代码里修就很难受了。3.2 Montage攻击节奏的真实表盘Sequence 只是“一段动作录像”真正用来组织攻击节奏的是 AnimMontage。我在 Montage 里最常做的事有三件切分 Section、设置 Slot、拖 AnimNotifyState。先建一个 AnimMontage选择攻击用的 Sequence然后看时间轴。ARPG 攻击动画通常可以按视觉阶段切成三个 Section起手到前摇结束是一段判定期是一段后摇收尾是一段。为什么要切 Section因为连招系统里经常需要根据输入时机跳到不同段落比如取消了后摇直接接下一段挥砍。如果不切后续要花大量精力写“第几帧到第几帧”的逻辑维护成本极高。Slot 这一步很关键。在 Montage 资产详情里有一个 Group 和 Slot 属性默认一般是DefaultGroup和DefaultSlot。这个名称必须和动画蓝图里的 Slot 节点名称对上否则播放 Montage 时动画会被状态机直接顶掉角色站在原地做走路姿势攻击动作完全不显示这是新手最常见的问题之一。Root Motion 的开关也在 Montage 里设置。ARPG 的攻击动画有两种做法一种是完全原地动画位移全部由逻辑代码处理另一种是攻击动画自带小幅度位移比如前踏步出刀、回身劈砍。自带位移就必须开启 Root Motion让根骨骼的移动直接驱动角色胶囊体这样动作和位移才会完全同步。3.3 AnimBP 里的 Slot用权重把攻击“压”出来动画蓝图的默认状态机里一般有一个 Slot 节点。攻击 Montage 播放时会在这个节点上按 Blending 权重直接覆盖底层状态机的输出。意思就是角色平时在播放 Idle、Run蒙太奇一播放Slot 节点把攻击动画“压”在现有的动画上面最终输出的就是攻击动作。这里有个细节容易忽略Slot 节点必须设置正确的 Slot Name。引擎自带的默认节点一般叫DefaultSlot如果你在 AnimBP 里手动新建了一个 Slott 节点并改名为CombatSlot那 Montage 资产里的 Slot 也要改成CombatSlot。两边对不上蒙太奇播放了等于没播。还有一个手感相关参数Blend In 和 Blend Out。我项目的攻击蒙太奇通常设置BlendIn 0.08~0.12BlendOut 0.2~0.3。Blend In 太快会显得出刀很“木”瞬间切换太慢又会觉得角色反映迟钝。这个参数没有标准答案但先按这个区间起步再根据测试结果微调是最省时间的做法。4. 按键到挥砍的完整链路C 触发起手的细节4.1 攻击键只是“请求”不是“播放”很多人写攻击手的第一步是直接在输入回调里Montage_Play看起来功能是通了但你会立刻发现两个问题第一角色在跳跃、死亡、已被敌人击退时也能出刀第二连续快速按键会让同一段蒙太奇被反复打断重启动画抽搐。正确的做法是把攻击键当成一个“请求”而不是“播放”。请求到达后角色先检查自己当前是否满足出刀条件满足才真正播放动画。// 角色类中的 TryAttack攻击触发的唯一入口 void ABaseCharacter::TryAttack() { if (!bIsAlive || bIsAttacking) { return; } // 如果角色正被硬直或处于受击状态这段应该通过状态 Tag 判断 // 这里用 bIsAttacking 做最简单的开关后续接连招时再扩展 if (UAnimInstance* AnimInst GetMesh()-GetAnimInstance()) { UAnimMontage* AttackToPlay GetNextAttackMontage(); if (!AttackToPlay) { return; } bIsAttacking true; AnimInst-Montage_Play(AttackToPlay, 1.0f); } }GetNextAttackMontage在初期可以直接返回一个固定 Montage也可以后续扩展成连击序列。这个函数存在的意义是把“播放哪个动画”和“能否播放”彻底分开。以后做连招、做变体武器、做持刀状态只需要改GetNextAttackMontage的内部逻辑完全不影响按键入口。4.2 触发起手的最小实现与回调收尾有了请求入口还要处理动画播放完之后的收尾。如果你只设bIsAttacking true却没人把它翻回去角色就会永远卡在攻击状态再也无法出刀。我这里用的是OnMontageEnded委托在BeginPlay时绑定到 AnimInstance 上。void ABaseCharacter::BeginPlay() { Super::BeginPlay(); if (UAnimInstance* AnimInst GetMesh()-GetAnimInstance()) { AnimInst-OnMontageEnded.AddDynamic(this, ABaseCharacter::OnAttackMontageEnded); AnimInst-OnMontageBlendingOut.AddDynamic(this, ABaseCharacter::OnAttackMontageEnded); } } void ABaseCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted) { if (Montage bIsAttacking) { bIsAttacking false; CurrentAttackIndex 0; } }为什么要同时绑OnMontageEnded和OnMontageBlendingOut因为动画结束有两种途径自然播完触发 End被Montage_Stop打断会进入 Blend Out这时只绑 End 可能不触发导致状态卡死。两个都绑并加一个bIsAttacking判断可以保证无论如何收尾角色都能恢复空闲状态。我不建议在TryAttack里直接写蒙太奇播完后的复杂逻辑比如“播放完就立刻重置某个变量再发送一个事件”。那会让攻击入口越来越臃肿。保持“TryAttack 只负责触发回调只负责收尾”的结构后续在回调里加连击重置、加攻击计数都会很干净。5. 攻击窗口与命中回调用 AnimNotifyState 画一条“刀锋活性区间”5.1 为什么窗口要用 Notify 而不是 Timer攻击判定最忌讳的是用代码里的FTimerHandle倒计时开关碰撞。表面看“0.3 秒开、0.6 秒关”也能跑通可一旦动画 Blend、变速、打断Timer 和动画时间轴就会错位动画才挥到一半碰撞已经关了玩家会感觉刀子明明砍中了却没有伤害。AnimNotifyState 是贴在动画时间轴上的标记它跟着动画采样走。动画在哪一帧窗口就在哪一秒开始动画被中途打断NotifyEnd也会立即触发不需要任何额外同步逻辑。这也是为什么我在第 3 章强调Montage 时间轴上要手动拖好MeleeActiveStart和MeleeActiveEnd。方案与动画同步打断处理变速支持适用场景AnimNotifyState精确自动支持攻击窗口、受击帧FTimer可能错位需要手动清不支持技能冷却、自动技能C 端实现一个最简的攻击窗口核心就是重写NotifyBegin和NotifyEnd。UE5.3 的函数签名里带了一个FAnimNotifyEventReference这是从 UE5.1 开始变化的老帖子里的代码大多数编译不过原因就在这。// MeleeWindowNotifyState.h UCLASS() class MYARPG_API UAnimNotifyState_MeleeWindow : public UAnimNotifyState { GENERATED_BODY() public: virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference EventReference) override; virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference EventReference) override; };// MeleeWindowNotifyState.cpp void UAnimNotifyState_MeleeWindow::NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference EventReference) { if (ABaseCharacter* Owner CastABaseCharacter(MeshComp-GetOwner())) { Owner-ActivateMeleeWindow(); } } void UAnimNotifyState_MeleeWindow::NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference EventReference) { if (ABaseCharacter* Owner CastABaseCharacter(MeshComp-GetOwner())) { Owner-DeactivateMeleeWindow(); } }5.2 武器碰撞开关与一帧多命中的去重窗口触发后角色要调用武器的SetAttackCollisionEnabled(true)。回到第 2 章的武器类这时只需要把AttackCollision的QueryOnly打开。等到NotifyEnd再关掉。void AWeaponBase::SetAttackCollisionEnabled(bool bEnabled) { if (!AttackCollision) { return; } AttackCollision-SetCollisionEnabled(bEnabled ? ECollisionEnabled::QueryOnly : ECollisionEnabled::NoCollision); if (bEnabled) { AlreadyHitActors.Empty(); } }这里我必须提一个非常常见的坑开启碰撞后同一把刀在同一个窗口里会多次触发OnComponentBeginOverlap。因为角色挥刀时武器组件会扫过敌人的多个碰撞体甚至同一帧内就可能触发两次。如果你直接在回调里结算伤害敌人会被一刀砍掉半管血。处理办法是用一个TSetTWeakObjectPtrAActor记录已经命中过的对象命中后加入集合重复命中直接跳过。窗口开启时清空集合窗口关闭时不处理。这样每一刀最多命中每个敌人一次伤害结算干净利落。遇到多段攻击时每段窗口之间会Empty一次相当于每段挥砍都是一个独立的判定区间。加连击时留意有些连招是“第一段命中后第二段自动接上”这种就不该清空命中集合否则同一敌人会被两段同时命中。具体按你的战斗设计来但基础逻辑就是这样。6. 实测复盘一下午踩出来的五个翻车点6.1 武器 Pivot 不对模型永远不在手心最先翻车的往往是建模导入阶段。武器模型在建模软件里的原点如果在脚底或者武器中心拖进 UE 之后Socket 就算调得再准武器看起来也像“悬浮”在手掌外面半米处。我当时调试了很久还在代码里写偏移量补偿结果发现完全没必要——直接在建模工具里把原点移到握柄中心重新导出问题瞬间消失。记住武器模型的原点就是它被世界坐标系摆放的锚点更是 Socket 对齐的基准点。6.2 Socket 对上了手型却把刀柄“架”在外侧第二种情况是 Socket 位置没错角度不对。手骨默认的方向轴和你想握剑的方向经常不同导致刀虽然贴在手心却是横着握的像拿了根棍子。解决方法是回到骨骼编辑器把 Socket 的旋转调到剑柄方向。调的时候最好把 T-Pose 作为参考一般握剑方向要和前臂延伸方向一致。我习惯在 Socket 下临时挂一个很长的 Debug Mesh用场景里摆一把剑对着调比纯看数字快得多。6.3 Montage 的 Slot 写错攻击动作直接被状态机吞掉最迷惑的 Bug 之一攻击逻辑完全正确角色却站在原地做待机动作。后来检查发现 Montage 的 Slot 是AttackSlotAnimBP 里的 Slot 节点却是默认的DefaultSlot。名称不匹配蒙太奇的内容根本没进入最终输出。排查方式很简单播放攻击时打开动画蓝图调试面板看 Slot 节点是否有动画进入或者临时在Montage_Play前打一条日志确认蒙太奇确实在播放。日志确认在播、画面没动那基本就是 Slot 名问题。6.4 Notify 窗口越位剑还没挥空气已经受击有段时间敌人会在主角起手动作刚摆出来时就被击飞。我一开始怀疑是碰撞体精度问题后来一帧帧放动画才发现把MeleeActiveStart的 Notify 放到了动画开头帧前摇还没结束判定已经开了。修复思路是把 AnimNotifyState 拖到动作的 Active 阶段也就是武器开始加速到最大速度的那一段。具体起点可以用慢动作播放肉眼确认不同动作差异很大不要照抄上一个动画的数值。6.5 Root Motion 的位移和胶囊体“打架”开启 Root Motion 后角色攻击会主动向前踏步但有时会出现“动画位移已经发生胶囊体却被推力弹回”的诡异表现或者攻击挥到一半自己飞了出去。原因多半是角色移动组件也在产生位移和 Root Motion 同时作用。我的处理方式攻击动画的位移如果不需要物理推动就在攻击窗口期间把移动组件的输入清零或者干脆让位移完全由Root Motion驱动、逻辑代码在攻击期间不写位移。更保守的方案是把攻击动画做成原地动作所有追击、突进都交给专门的技能逻辑这样最不容易出问题只是实现连招手感时需要额外调距离。说实话武器和动画的准备阶段在项目里往往不被人当回事但这一层没打稳后面接什么都像在烂地基上盖楼。我个人的习惯是每次改完攻击动画先开慢动作播放跑一遍判定窗口再让团队里的另一个人来试手感——自己调过的节奏容易麻木旁观者的一拳往往比参数表更能暴露出刀慢不慢的问题。最后分享一个小技巧攻击动画调好之后把每个 Montage 的MeleeActiveStart和MeleeActiveEnd数值记在项目 Wiki 里以后做各种敌人、各种武器时都能直接复用这套节奏基准省掉大量回头微调的工夫。
阅读完成 · 觉得有帮助?
咨询建站