
1. 为什么要在 GAS RPG 里做鼠标点击移动做 UE5 GASGameplay Ability SystemRPG 的朋友大概率都遇到过这个纠结角色移动到底用哪套方案。第三人称模板自带的 CharacterMovement 配合输入映射做键盘 WASD 很顺但一旦要做类暗黑、类博德之门那种鼠标点击寻路很多人第一反应是上 NavMesh 加 AI MoveTo结果发现 AI MoveTo 是给 AIController 用的玩家控制的 Pawn 直接调会有一堆坑——比如移动组件和 GAS 的 Root Motion、Motion Warping 打架比如点击移动和技能位移的优先级混乱再比如网络同步下客户端预测和服务端校正对不上。我这次要聊的就是把UE5 GAS RPG 的鼠标点击移动这套东西从头捋一遍。核心目标很明确玩家在场景里点一下地面角色自动寻路走过去点敌人走到攻击距离内停下并触发攻击移动过程中按技能键能打断移动施法整套逻辑在 GAS 框架下走 Ability 体系而不是散落在 PlayerController 里的一堆 if-else。适合谁看已经搭好 GAS 基础框架ASC、AttributeSet、Ability 都跑通了、现在卡在移动和技能怎么协同这一步的中级开发者。纯新手建议先把 GAS 的 Ability 激活流程和 GameplayTag 机制摸熟再回来不然下面很多设计取舍你会看得云里雾里。这套方案解决的核心痛点有三个。第一是输入与移动的解耦点击移动本质是给一个目标点让角色自己走过去它和按住 W 往前走是两种完全不同的输入语义硬塞进同一个 InputAction 里会很难受。第二是移动状态的可中断性走位途中放技能、被控制、被击退这些都要能干净地打断寻路否则角色会出现一边放技能一边滑步的鬼畜画面。第三是网络一致性点击移动的路径计算如果只在客户端做服务端不认就会出现位置回拉。这三点想清楚了方案基本就定型了。2. 整体方案设计与技术选型拆解2.1 三条技术路线的取舍在 UE5 里实现鼠标点击移动主流有三条路我先把对比摆出来再说我为什么选第三条。方案实现方式优点致命问题A. 纯 CharacterMovement手动算方向AddMovementInput简单直接没有寻路遇到障碍就卡住绕不开墙B. AIController MoveTo给玩家 Pawn 挂 AIController用 MoveToActor/MoveToLocation自带寻路NavMesh 全自动玩家 Pawn 挂 AIController 会和 PlayerController 抢控制权输入、相机、GAS 激活全乱C. 自定义 Ability NavMesh 寻路请求在 GAS Ability 里发起 PathFollowingComponent 的寻路请求走 Ability 体系可中断、可预测、可同步需要自己处理路径跟随和移动组件的衔接方案 B 是新手最容易踩的坑。表面上看给玩家挂个 AIController 不就完事了实际上 UE 的架构里 PlayerController 和 AIController 是互斥的一个 Pawn 只能被一个 Controller 占有。你强行换 Controller输入系统Enhanced Input的 PlayerController 绑定就断了相机管理也乱套。我见过有人用双 Controller 切换的骚操作绕过去结果网络同步时 Ownership 一团糟最后全推翻重来。方案 C 是我推荐的。核心思路是不换 Controller而是在玩家 Pawn 上直接使用 PathFollowingComponent 的能力。具体做法是给 Character 添加一个UPathFollowingComponent或者复用 AIController 那套但通过组件方式挂载然后在 GAS Ability 里发起寻路请求Ability 负责整个生命周期——激活时请求路径Tick 时跟随路径被中断时清理路径。这样移动就变成了一个可持续的 Ability天然支持 GAS 的打断、标签、消耗等机制。2.2 为什么用 Ability 承载移动而不是 PlayerController有人会问点击移动逻辑放 PlayerController 里不也能跑吗为什么要套一层 Ability这个问题的答案决定了整套架构的健壮性。放 PlayerController 里的问题在于移动状态和技能状态是两套独立的状态机你得手动维护现在能不能移动移动中能不能放技能这些互斥关系。项目小的时候还能靠 if-else 撑住一旦技能多了冲锋、击退、定身、眩晕状态组合爆炸bug 层出不穷。而 GAS 的 Ability 天生带GameplayTag 阻塞机制你给点击移动 Ability 打上Ability.Move.ClickToMove标签给冲锋打上Ability.Move.Dash在 Ability 的ActivationBlockedTags里配置互斥引擎自动帮你处理冲锋时不能点击移动点击移动时不能冲锋这些规则不用写一行判断代码。另一个关键原因是网络预测。GAS 的 Ability 支持客户端预测激活点击移动这种高频操作如果每次都等服务端确认手感会明显延迟。走 Ability 体系可以开启预测客户端先动服务端校正配合WaitTargetData还能做目标点的预测确认。这套东西自己从零写工作量巨大且容易出同步 bug。2.3 组件与类的职责划分方案定了接下来是职责划分。我习惯把整套东西拆成四块ClickToMoveAbility继承UGameplayAbility负责寻路请求的发起、路径跟随的 Tick、中断时的清理。它是整个移动逻辑的大脑。PathFollowingComponent挂在 Character 上负责实际的路径点跟随和移动组件驱动。UE 自带的这个组件质量很高直接复用别自己造。PlayerController 里的输入处理只负责把鼠标点击转成世界坐标然后通过TryActivateAbilityByTag激活移动 Ability把目标点作为参数传进去。它不碰任何移动逻辑。GameplayTag 体系定义Ability.Move.ClickToMove、State.Moving、Block.Move等标签作为各系统间的通信契约。这个划分的关键在于输入层极薄。PlayerController 只做翻译把点击翻译成 Ability 激活请求剩下的全交给 Ability。这样以后要加手柄摇杆点击移动触屏点按移动只需要在输入层加映射Ability 层完全不用动。3. 核心细节解析与实操要点3.1 寻路请求的正确发起姿势寻路请求这块很多人第一反应是UNavigationSystemV1::FindPathToLocationSynchronously同步寻路一行代码拿到路径。小场景没问题但一旦地图大了、NavMesh 复杂了同步寻路会卡主线程点击瞬间掉帧。正确做法是异步寻路用FindPathAsync或者更推荐的UPathFollowingComponent::RequestMove。我实际用的是 PathFollowingComponent 的RequestMove接口它内部封装了异步寻路和路径跟随你只需要传一个FAIMoveRequest。关键参数有三个目标位置、接受半径AcceptanceRadius、是否允许部分路径bAllowPartialPath。接受半径这个值很讲究设太小角色会在目标点附近反复微调抖动设太大又走不到位。我的经验值是角色胶囊体半径的 1.5 到 2 倍比如半径 40 的角色接受半径设 60 到 80 比较舒服。FAIMoveRequest MoveRequest; MoveRequest.SetGoalLocation(TargetLocation); MoveRequest.SetAcceptanceRadius(70.f); MoveRequest.SetAllowPartialPath(true); MoveRequest.SetUsePathfinding(true); FPathFollowingRequestResult Result PathFollowingComp-RequestMove(MoveRequest);bAllowPartialPath设 true 很重要。玩家点了一个 NavMesh 覆盖不到的角落比如悬崖边、装饰物后面如果设 false寻路直接失败角色纹丝不动玩家会觉得点了没反应。设 true 的话角色会走到能到达的最近点体验上更符合直觉。注意RequestMove 返回的FPathFollowingRequestResult里有个MoveId一定要存下来。后面中断移动、判断当前移动是不是我发起的都要靠它。我踩过的坑就是没存 MoveId结果技能位移和点击移动同时存在时清理逻辑把别人的路径也清了。3.2 Ability 生命周期与路径跟随的绑定点击移动 Ability 的生命周期管理是整套方案最容易出 bug 的地方。核心原则是Ability 激活时发起寻路Ability 结束时必须清理路径。听起来简单但结束的时机有很多种正常到达、被玩家新点击覆盖、被技能打断、角色死亡、关卡切换。每一种都要处理干净。我的做法是在 Ability 里重写ActivateAbility、EndAbility并在ActivateAbility里绑定 PathFollowingComponent 的OnRequestFinished委托。到达目标点时委托触发调用EndAbility正常结束。被外部打断时比如CancelAbility在EndAbility里统一调PathFollowingComp-AbortMove清理。void UClickToMoveAbility::ActivateAbility(...) { Super::ActivateAbility(...); // 发起寻路 FPathFollowingRequestResult Result PathComp-RequestMove(MoveRequest); if (Result.Code EPathFollowingRequestResult::Failed) { EndAbility(Handle, ActorInfo, ActivationInfo, true, true); return; } CurrentMoveId Result.MoveId; // 绑定到达回调 PathComp-OnRequestFinished.AddUObject(this, UClickToMoveAbility::OnMoveFinished); } void UClickToMoveAbility::EndAbility(...) { if (PathComp.IsValid() CurrentMoveId.IsValid()) { PathComp-AbortMove(*this, FPathFollowingResultFlags::UserAbort, CurrentMoveId); } PathComp-OnRequestFinished.RemoveAll(this); Super::EndAbility(...); }这里有个细节AbortMove的第二个参数FPathFollowingResultFlags要选对。正常打断用UserAbort如果是被技能强制位移打断用ForcedScript或者自定义 flag方便在日志里区分打断来源。调试阶段这个 flag 能帮你快速定位到底是谁把移动干掉了。3.3 点击目标点的坐标转换与合法性校验鼠标点击转世界坐标标准做法是DeprojectScreenPositionToWorld拿到射线起点和方向然后和地面做LineTrace。但这里有几个坑必须处理。第一个坑是点击到角色自己身上。射线打到角色胶囊体返回的命中点是角色身上的点不是地面点寻路会失败或者走到奇怪的位置。解决办法是 LineTrace 的IgnoreActor里把玩家自己的 Pawn 加进去或者用ECC_Visibility通道但给角色胶囊体设NoCollision响应不推荐会影响其他逻辑。我选前者简单直接。第二个坑是点击 UI 时穿透到场景。玩家点技能栏按钮结果角色也往那个方向走了。这个要在输入处理里判断IsOverGameObject或者用 Enhanced Input 的输入模式切换UI 打开时禁用移动输入映射。Enhanced Input 的InputMappingContext优先级机制很适合干这个UI 打开时 push 一个高优先级的空映射把移动输入吃掉。第三个坑是目标点不在 NavMesh 上。射线打到地面但那个点可能在地形缝隙、装饰物内部NavMesh 没覆盖。前面说的bAllowPartialPath能兜底但更好的做法是拿到命中点后用ProjectPointToNavigation把它投影到最近的 NavMesh 点上投影失败再放弃。FVector HitLocation TraceResult.Location; FNavLocation ProjectedLocation; if (NavSys-ProjectPointToNavigation(HitLocation, ProjectedLocation, FVector(200, 200, 500))) { TargetLocation ProjectedLocation.Location; } else { // 投影失败放弃本次移动 return; }投影的 Extent 参数FVector(200, 200, 500)是搜索范围Z 轴给大一点是因为地形高低差可能较大。这个值根据你的关卡尺度调开放世界给 500 到 1000 都正常。3.4 移动与技能互斥的标签设计GAS 的标签体系是整套方案优雅的关键。我设计的标签分三类Ability 标签Ability.Move.ClickToMove、Ability.Move.Dash、Ability.Attack.Melee标识哪个技能在跑。State 标签State.Moving、State.Casting、State.Stunned标识角色当前状态。Block 标签Block.Move、Block.Ability标识什么被禁止了。互斥规则这样配ClickToMoveAbility 的ActivationBlockedTags里放State.Stunned、State.Casting、Block.Move同时它的ActivationOwnedTags里放State.Moving。这样角色施法时点地面不会动被眩晕时不会动而移动中放技能时技能 Ability 的ActivationBlockedTags里如果配了State.Moving就会自动打断移动——但通常我们不希望移动完全阻塞技能而是希望放技能时自动停下移动这个用CancelAbilitiesWithTag更合适。技能 Ability 里配CancelAbilitiesWithTag Ability.Move.ClickToMove效果就是放技能的瞬间点击移动 Ability 被取消角色停下技能正常释放。这个配置比在代码里手动判断优雅太多而且新增技能时只要继承基类就自动生效。提示标签命名一定要有层级感用点号分隔。Ability.Move.ClickToMove比ClickToMoveAbilityTag好维护得多因为 GAS 的标签匹配支持父级匹配Ability.Move能匹配所有移动类技能做批量取消时特别方便。4. 实操过程与核心环节实现4.1 环境准备与组件挂载动手之前先把基础环境确认一遍。UE5 版本我用的 5.3GAS 插件在 5.3 里已经比较稳定但记得在.uproject里启用GameplayAbilities、GameplayTags、GameplayTasks三个插件缺一个编译就报错。Character 蓝图里确认CharacterMovement组件的Orient Rotation to Movement勾上Use Controller Rotation Yaw取消勾选这样角色朝向跟随移动方向而不是相机方向点击移动时转身才自然。组件挂载这一步在 Character 的构造函数里加 PathFollowingComponentUClickToMoveCharacter::UClickToMoveCharacter() { PathFollowingComp CreateDefaultSubobjectUPathFollowingComponent(TEXT(PathFollowingComp)); // 注意PathFollowingComponent 需要手动关联 MovementComponent }这里有个隐藏坑UPathFollowingComponent默认期望它的 Owner 是 AIController直接挂在 Character 上时它内部的MovementComp引用是空的RequestMove会失败。解决办法是在BeginPlay里手动设置void UClickToMoveCharacter::BeginPlay() { Super::BeginPlay(); if (PathFollowingComp GetCharacterMovement()) { PathFollowingComp-SetMovementComponent(GetCharacterMovement()); } }SetMovementComponent这个接口在部分版本里是 protected 的如果编译不过可以继承一个自定义的 PathFollowingComponent 子类在子类里暴露一个 public 的初始化方法。我实际项目里就是这么干的顺便在子类里加了移动速度的动态调整逻辑。4.2 输入映射与点击事件绑定Enhanced Input 的配置分三步建 InputAction、建 InputMappingContext、在 PlayerController 里绑定。点击移动只需要一个IA_ClickToMove类型选Digital (bool)绑定鼠标左键。PlayerController 里的绑定逻辑void AMyPlayerController::SetupInputComponent() { Super::SetupInputComponent(); if (UEnhancedInputComponent* EIC CastUEnhancedInputComponent(InputComponent)) { EIC-BindAction(ClickToMoveAction, ETriggerEvent::Started, this, AMyPlayerController::OnClickToMoveStarted); } } void AMyPlayerController::OnClickToMoveStarted() { FHitResult Hit; if (GetHitResultUnderCursor(ECC_Visibility, false, Hit)) { // 校验、投影、激活 Ability TryActivateClickToMove(Hit.Location); } }GetHitResultUnderCursor是 PlayerController 自带的便捷函数内部就是 Deproject LineTrace比自己拼省事。但注意它的bTraceComplex参数设 false用简单碰撞就行复杂碰撞在复杂场景里性能开销明显。激活 Ability 时传目标点用FGameplayEventData或者 Ability 的TargetData。我倾向用WaitTargetData配合TargetActor虽然配置稍麻烦但网络同步更可靠。简化版可以直接用FGameplayAbilitySpec的动态参数但那种方式在预测回滚时容易丢数据正式项目别偷这个懒。4.3 寻路请求的完整实现与参数调优把前面几块拼起来ClickToMoveAbility 的完整激活流程是这样的void UClickToMoveAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) { Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData); if (!CommitAbility(Handle, ActorInfo, ActivationInfo)) { EndAbility(Handle, ActorInfo, ActivationInfo, true, true); return; } ACharacter* Character CastACharacter(ActorInfo-AvatarActor.Get()); UPathFollowingComponent* PathComp Character-FindComponentByClassUPathFollowingComponent(); FVector TargetLoc ExtractTargetLocation(TriggerEventData); FAIMoveRequest MoveReq(TargetLoc); MoveReq.SetAcceptanceRadius(AcceptanceRadius); MoveReq.SetAllowPartialPath(true); MoveReq.SetUsePathfinding(true); MoveReq.SetProjectGoalLocation(true); FPathFollowingRequestResult Result PathComp-RequestMove(MoveReq); if (Result.Code EPathFollowingRequestResult::Failed) { EndAbility(Handle, ActorInfo, ActivationInfo, true, true); return; } CurrentMoveId Result.MoveId; PathComp-OnRequestFinished.AddUObject(this, UClickToMoveAbility::HandleMoveFinished); // 设置移动速度可选走 AttributeSet 的 MoveSpeed Character-GetCharacterMovement()-MaxWalkSpeed GetMoveSpeedFromAttribute(); }参数调优这块AcceptanceRadius前面说了胶囊体半径的 1.5 到 2 倍。SetProjectGoalLocation(true)让引擎自动把目标点投影到 NavMesh省得自己调ProjectPointToNavigation但它的搜索范围是固定的复杂地形下可能投影失败所以我在输入层还是保留了一次手动投影做双保险。移动速度从 AttributeSet 读这是 GAS RPG 的标准做法。角色有MoveSpeed属性装备、buff 改这个值点击移动时实时读取这样加速鞋减速debuff自动生效不用在移动逻辑里写任何特殊判断。4.4 到达判定与移动结束的清理OnRequestFinished委托触发时要区分是正常到达还是被打断。FPathFollowingResult里的Code字段能区分Success是到达Interrupted是被打断Aborted是被主动取消。正常到达时结束 Ability被打断时也要结束但可以触发不同的后续逻辑比如被打断时播放一个踉跄动画。void UClickToMoveAbility::HandleMoveFinished(FPathFollowingResult Result) { if (Result.Code EPathFollowingResult::Success) { // 到达目标点可以触发到达事件比如到达敌人身边自动攻击 OnReachedDestination(); } EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, false); }EndAbility的最后一个参数bReplicateEndAbility设 false 还是 true 要看场景。点击移动这种高频操作设 false 减少网络流量服务端靠位置同步自然校正。但如果是到达后触发攻击这种有游戏逻辑后果的设 true 保证服务端也执行。清理阶段除了AbortMove还要记得把MaxWalkSpeed恢复默认值如果前面改过以及移除委托绑定。委托不移除是内存泄漏的常见来源Ability 实例被 GC 后委托还挂着下次触发就崩。RemoveAll(this)这行别省。5. 常见问题与排查技巧实录5.1 角色原地抖动或反复微调这是最高频的问题。表现是角色走到目标点附近开始左右小幅抖动停不下来。原因通常是AcceptanceRadius设得太小角色在半径边缘反复进入-退出PathFollowingComponent 不断重新计算微调路径。排查思路先把AcceptanceRadius临时调到 200如果抖动消失就是半径问题。正常值回到胶囊体半径的 1.5 到 2 倍。另一个可能原因是CharacterMovement的BrakingDecelerationWalking太小角色减速太慢冲过目标点再往回走。这个值默认 2048可以适当调大到 3000 左右让角色到点即停。还有一种隐蔽情况NavMesh 在目标点附近有多个不连通的碎片角色在两个碎片间反复寻路。用Show Navigation可视化看一下目标点周围的 NavMesh 覆盖有断裂就重新烘焙。5.2 点击后角色不动也不报错点了地面角色纹丝不动日志里也没有寻路失败。这种静默失败最烦人。排查顺序我总结成一张表排查项检查方法常见原因Ability 是否激活在 ActivateAbility 里打 Log标签阻塞、Cost/Cooldown 未满足寻路是否发起在 RequestMove 后打 Log 看 Result.CodePathFollowingComp 未关联 MovementCompNavMesh 是否存在按 P 键可视化关卡没放 NavMeshBoundsVolume目标点是否合法打印投影后的坐标点击到天空或 UI射线没命中移动组件是否被禁用检查 MovementMode被其他逻辑设成了 None我遇到过一次特别坑的Ability 激活了寻路也发起了Result.Code是RequestSuccessful但角色就是不动。查了半天发现是另一个待机Ability 在 Tick 里每帧调StopMovementImmediately把移动组件锁死了。这种问题只能靠ShowDebug AbilitySystem和移动组件的详细日志对时间线才能定位。5.3 移动中放技能出现滑步角色一边走一边放技能技能动画在播但角色还在往前滑。原因是技能 Ability 没有取消移动 Ability两套逻辑并行。解决就是前面说的CancelAbilitiesWithTag配置技能 Ability 里加上Ability.Move.ClickToMove。但配了之后可能出现新问题技能放完角色停在原地玩家得重新点一下才继续走。如果产品需求是放完技能自动继续之前的移动那就要在技能结束时重新激活移动 Ability把之前的目标点存下来。这个需求看项目类暗黑通常不自动继续玩家要重新决策类 MOBA 有些会自动继续。我建议默认不自动继续逻辑简单且符合玩家预期。5.4 网络环境下位置回拉联机测试时客户端点击移动角色走出去两步又被拉回原点。这是典型的客户端预测和服务端校正冲突。原因通常是移动 Ability 只在客户端激活服务端没跑服务端认为角色没动位置同步时把客户端拉回来。解决要点Ability 的NetExecutionPolicy设成LocalPredicted保证客户端预测、服务端权威执行。同时CommitAbility要在服务端也走一遍Cost 和 Cooldown 的扣除要服务端确认。另外检查CharacterMovement的NetworkSmoothingMode设成Exponential能明显减少回拉的视觉突兀感。还有一个容易忽略的点RequestMove在服务端和客户端算出的路径可能因为 NavMesh 数据版本不一致而不同。确保服务端和客户端的 NavMesh 是同一份烘焙数据联机时版本不匹配会导致路径分叉进而位置对不上。5.5 点击移动与相机旋转的冲突类暗黑视角下相机是俯视固定角度点击移动没问题。但如果项目是第三人称越肩视角玩家一边转相机一边点地面角色朝向会跟着相机转走起来很别扭。这是因为bOrientRotationToMovement和bUseControllerRotationYaw的配置冲突。正确配置bOrientRotationToMovement truebUseControllerRotationYaw false。这样角色朝向永远跟随移动方向相机怎么转都不影响。如果希望移动时朝移动方向站立时朝相机方向那要在移动结束时手动把朝向插值到相机 forward这个属于进阶需求用 Timeline 或者RInterpTo在 Tick 里做平滑过渡。注意bOrientRotationToMovement开启后RotationRate的 Yaw 值决定转身速度。默认 540 度每秒点击移动时如果目标点在身后角色会原地转半圈再走观感上有点迟钝。调到 720 到 900 会利落很多具体看角色体型重型角色慢一点更真实。6. 性能优化与扩展思路6.1 寻路开销的控制点击移动的寻路开销主要来自两方面NavMesh 查询和路径跟随的 Tick。NavMesh 查询是异步的单次开销不大但玩家疯狂连点时会堆积大量寻路请求。我的处理是在输入层加一个节流两次点击间隔小于 0.15 秒的只保留最后一次。实现上用FTimerHandle或者记录LastClickTime判断简单有效。路径跟随的 Tick 开销PathFollowingComponent 内部已经做了优化只在有路径时 Tick空闲时不消耗。但要注意OnRequestFinished委托的绑定和移除要成对否则委托列表越来越长每次触发遍历开销线性增长。我在项目后期做过一次 Profile发现某个版本委托没清理干净单帧遍历了几百个空委托占了 0.3ms清理后恢复正常。6.2 点击敌人自动追击的扩展纯点地面移动只是基础RPG 里更常见的是点敌人走到攻击距离内自动攻击。这个扩展在现有架构上很好加输入层做射线检测时先判断命中的是不是敌人用ECC_Pawn通道或者 Actor Tag如果是敌人把目标 Actor而不是目标位置传给 Ability。Ability 里用MoveToActor而不是MoveToLocationAcceptanceRadius设成攻击距离比如 150到达后触发攻击 Ability。敌人移动时MoveToActor会自动更新目标位置角色持续追击。这个功能 PathFollowingComponent 原生支持不用自己写追击逻辑。6.3 移动轨迹的视觉反馈点击移动如果没有视觉反馈玩家会不确定到底点没点中。标准做法是点地面时在目标点生成一个 Decal 或者 Niagara 粒子短暂显示后消失。Decal 的性能开销比粒子小我倾向用 Decal材质里做一个圆环扩散动画0.5 秒后自动销毁。这个反馈的生成时机在输入层射线命中后立刻生成不用等 Ability 激活成功。即使寻路失败点到不可达区域反馈也照常显示但颜色可以区分——可达绿色不可达红色。这个细节对操作手感提升很大玩家能立刻知道这一下点得对不对。6.4 手柄与触屏的输入适配前面说过输入层极薄的好处这里就体现出来了。手柄适配只需要加一个IA_StickClickMove把摇杆方向转成世界坐标偏移作为目标点传给同一个 Ability。触屏则是把触摸点转屏幕坐标走和鼠标一样的 Deproject 流程。Ability 层一行代码不用改这就是架构分层的价值。手柄有个特殊点摇杆是持续输入不是单次点击。处理方式是按住摇杆时持续更新目标点每 0.1 秒更新一次松开时停止。这相当于持续点击移动PathFollowingComponent 的RequestMove支持重复调用新请求会自动覆盖旧请求不用手动 Abort。7. 我在实际项目里的几点体会这套方案从第一版跑通到线上稳定前后迭代了大概两个月踩的坑基本都写在上面了。最后分享几个文档里不会写、但实际开发中很影响效率的经验。第一个是调试可视化要早做。我在 Ability 里加了一个DrawDebugPath的开关激活时把寻路路径画出来红色线段显示实际走的路线。这个功能在排查为什么角色绕远路为什么走到一半停了时救命比看日志快十倍。建议在项目早期就加上用#if !UE_BUILD_SHIPPING包起来发布版自动剔除。第二个是标签体系要一次设计到位。我第一版标签命名很随意ClickMove、MoveAbility、CanMove混着用后期加技能时发现匹配规则对不上重构标签花了两天。教训是Ability 标签统一Ability.前缀状态标签统一State.前缀阻塞标签统一Block.前缀层级至少两级。这个规范定下来后后面加任何技能都是配置工作不用改代码。第三个是接受半径和移动速度要联动。角色加速跑的时候如果接受半径不变高速下容易冲过目标点再折返。我的处理是接受半径随速度动态调整速度越快半径越大公式大概是Radius BaseRadius Speed * 0.02。这个微调让高速移动的到达判定顺滑很多尤其是骑马、加速冲刺这类场景。第四个是别迷信引擎默认值。PathFollowingComponent的很多默认参数是给 AI 设计的玩家操控的点击移动需求不同。比如MinAgentRadiusPct、bStopOnOverlap这些默认值下角色会贴着障碍物走观感上很挤。适当调大避障半径让角色走位留出余量视觉上舒服很多。这些参数没有标准答案只能在自己的关卡里反复试试到顺眼为止。这套东西后续还能往编队移动路径点队列移动中攻击走 A方向扩展架构上都支持无非是在 Ability 里加状态机。走 A 的实现思路是在移动 Ability 的 Tick 里检测攻击范围内是否有敌人有就暂停移动触发攻击攻击完继续走本质是移动和攻击两个 Ability 的交替激活用标签互斥控制节奏。这块内容展开又是另一篇了先把基础的点击移动跑稳再往上叠功能地基不牢后面全是返工。