UE5.8 目标锁定与朝向控制:从黑魂到通用战斗系统

📑 目录

战斗中的镜头:一次失败的 Boss 战

《黑暗之魂》或《艾尔登法环》里有一种让人血压飙升的瞬间。你举着盾绕到熔炉骑士侧面,按下右摇杆,屏幕中央跳出一个白点,角色自动转向敌人,镜头微微拉近。你翻滚、砍一刀、再翻滚,Boss 突然化作一道金光瞬移到场地另一侧。你狂按锁定键,镜头却没有跟过去,白点悬在原地三秒后消失,角色还朝着空气挥了一剑。

这就是目标锁定系统出错时的代价。在动作游戏里,锁定不只是一个 UI 白点,它同时控制着角色的朝向、相机的旋转、移动的相对坐标,乃至攻击动画的出招方向。UE5.8 没有内置一套完整的 souls-like 锁定方案,但它提供了足够的基础设施:SpringArm、CameraManager、CharacterMovementComponent、Enhanced Input、GameplayAbilitySystem、网络复制。把这些模块拼起来,就能做出稳定可靠的锁定与朝向控制。

这篇文章会拆解我最近在 UE5.8 里实现的一套锁定系统,覆盖 Soft Lock 与 Hard Lock 的区别、目标评分算法、自动转向、相机相对移动、目标丢失与重新锁定、多目标切换、动画与攻击方向,以及多人模式下的网络同步。所有核心逻辑用 C++ 完成,关键参数暴露给蓝图做调整。

Soft Lock 与 Hard Lock

目标锁定可以粗略分成两种模式。

Soft Lock 只改变角色的朝向。当你推动左摇杆向前,角色会朝当前锁定目标转动,但镜头仍然由玩家直接控制。这种模式常见于弓箭瞄准、远程技能、以及部分第三人称射击游戏的辅助瞄准。玩家保留对相机的完全控制权,系统只在水平面上帮你把角色转向目标。

Hard Lock 则把相机也接管了。按下锁定键后,镜头会固定在目标身上,玩家推动右摇杆只能微调俯仰角,不能自由环绕。左摇杆的前、后、左、右全部变成以目标为参考的相对方向。魂类游戏就是这种方案,近战攻击必须面向目标才能保证命中判定合理。

两种模式各有适用场景。近战系统通常用 Hard Lock,远程或群攻系统可能 Soft Lock 更合适。在我的项目里,普通移动用 Soft Lock,进入战斗姿态后切换为 Hard Lock。切换点放在 Gameplay Ability 的激活与退出事件里,通过标签区分状态。

我用一个枚举定义锁定强度:

UENUM(BlueprintType)
enum class ELockMode : uint8
{
    None,
    Soft,
    Hard
};

系统架构

目标锁定系统拆成四个模块:目标检测器、评分器、锁定控制器、朝向控制器。检测器负责收集候选目标;评分器根据距离、角度、可见性算出最佳目标;锁定控制器管理当前目标的状态和生命周期;朝向控制器把锁定信息转换成角色旋转和移动输入。相机管理由 SpringArm 和目标偏移组件共同完成。

architecture-beta
    group detection(server)[目标检测]
        service sphere(server)[球形扫描] in detection
        service line(server)[视线射线] in detection
        service ray(server)[遮挡射线] in detection

    group scoring(database)[评分器]
        service distance(database)[距离评分] in scoring
        service angle(database)[角度评分] in scoring
        service visibility(database)[可见性评分] in scoring

    group lockctrl(cloud)[锁定控制]
        service softlock(cloud)[Soft Lock] in lockctrl
        service hardlock(cloud)[Hard Lock] in lockctrl
        service statemachine(cloud)[状态机] in lockctrl

    group orientation(server)[朝向控制]
        service turn(server)[自动转向] in orientation
        service movement(server)[移动朝向] in orientation
        service camera(server)[相机控制] in orientation

    sphere:B --> T:distance
    line:B --> T:visibility
    ray:B --> T:visibility
    distance:B --> T:softlock
    angle:B --> T:softlock
    visibility:B --> T:softlock
    softlock:B --> T:hardlock
    hardlock:B --> T:statemachine
    statemachine:B --> T:turn
    statemachine:B --> T:movement
    turn:B --> T:camera

目标选择:距离、角度与可见性

锁定第一步是找出玩家想打谁。最朴素的办法是遍历一定半径内的所有敌人,按距离排序选最近。这在开阔场地没问题,可一旦场景里有柱子、墙壁、其他敌人,单纯距离会给出荒谬结果。玩家明明看着正前方的敌人,系统却锁定到背后三米外的骷髅兵。

更好的做法是设计一个评分函数,把距离、角度、可见性加权求和:

S=wdf(d)+wag(θ)+wvh(v)S = w_d \cdot f(d) + w_a \cdot g(\theta) + w_v \cdot h(v)

其中 dd 是角色到目标的距离,f(d)f(d) 是归一化距离分;θ\theta 是目标相对于角色正前方的水平夹角,g(θ)g(\theta) 是角度分;vv 是可见性布尔值或可见比例,h(v)h(v) 是可见性分。三个权重 wdw_dwaw_awvw_v 可以在蓝图中按职业或武器调整。

距离分的计算需要处理太近和太远两种情况。我倾向于用高斯衰减:

f(d)=e(ddideal)22σ2f(d) = e^{-\frac{(d - d_{ideal})^2}{2\sigma^2}}

dideald_{ideal} 是理想锁定距离,比如 3.5 米;σ\sigma 控制衰减速度。目标贴脸时分数不会断崖式下跌,太远时也不会出现负分。

角度分用余弦函数最直观:

g(θ)=1+cos(θ)2g(\theta) = \frac{1 + \cos(\theta)}{2}

当目标在正前方时 θ=0\theta = 0g(θ)=1g(\theta) = 1;在正后方时 θ=π\theta = \pig(θ)=0g(\theta) = 0。这个值可以直接和距离分相乘,保证玩家不会意外锁定到背后的敌人。

可见性用射线检测。从角色眼睛位置向目标中心、头部、胸部各打一条射线,只要有一条没有被 WorldStatic 或 WorldDynamic 通道阻挡,就认为可见。为了提高稳定性,我会给最近命中的目标额外加分,避免玩家在镜头边缘的目标和正前方目标之间来回跳。

整个选择流程可以用下图表示:

flowchart TD
    A[玩家按下锁定键] --> B{是否已有锁定目标}
    B -->|是| C[取消当前锁定]
    B -->|否| D[球形扫描收集候选]
    D --> E[剔除死亡、不可锁定对象]
    E --> F[计算距离分、角度分、可见性]
    F --> G[按总分排序]
    G --> H{最高分是否高于阈值}
    H -->|是| I[切换为 Hard Lock]
    H -->|否| J[保持无锁定状态并提示]

代码实现大概这样:

float ULockOnSubsystem::ScoreTarget(const AActor* Target) const
{
    const FVector OwnerLoc = GetOwner()->GetActorLocation();
    const FVector TargetLoc = Target->GetActorLocation();
    const float Dist = FVector::Dist2D(OwnerLoc, TargetLoc);
    const float DistScore = FMath::Exp(
        -FMath::Square(Dist - IdealDistance) / (2.0f * DistanceVariance)
    );

    const FVector DirToTarget = (TargetLoc - OwnerLoc).GetSafeNormal2D();
    const FVector OwnerForward = GetOwner()->GetActorForwardVector();
    const float Dot = FVector::DotProduct(DirToTarget, OwnerForward);
    const float AngleScore = (1.0f + Dot) * 0.5f;

    const float VisScore = TraceVisibility(Target) ? 1.0f : 0.0f;

    return DistanceWeight * DistScore
         + AngleWeight * AngleScore
         + VisibilityWeight * VisScore;
}

自动转向与移动朝向

锁定成功后,角色需要持续面向目标。这里最容易踩的坑是直接设置 ActorRotation。如果你每帧都调用 SetActorRotation,角色会瞬间转身,看起来像是机械臂,而且会把 CharacterMovementComponent 的速度方向搞乱。

UE5.8 推荐的做法是用 UCharacterMovementComponent::NetworkSmoothing 配合 FRotator 插值。我通常在 Tick 里计算目标旋转,然后调用 MoveUpdatedComponent 或者设置 ControlRotation。具体做法取决于你的移动模式。

对于 Hard Lock,角色朝向可以这样计算:

dtarget=ptargetpselfptargetpself\mathbf{d}_{target} = \frac{\mathbf{p}_{target} - \mathbf{p}_{self}}{\|\mathbf{p}_{target} - \mathbf{p}_{self}\|}

目标偏航角 ϕ\phidtarget\mathbf{d}_{target} 在水平面上的分量决定:

\phi = \operatorname{atan2}(d_y, d_x)

当前偏航角 ϕcurrent\phi_{current} 与目标偏航角之间的差值要限制在最大转速以内:

\Delta \phi = \operatorname{clamp}(\phi_{target} - \phi_{current}, -\omega_{max}\Delta t, \omega_{max}\Delta t)

最后把新的偏航角写回 ControlRotation。这样角色会以平滑的角速度转向敌人,而不是瞬移。

注意,转向速度要分状态。普通移动时用较低的角速度,攻击前摇时可以更快,格挡时甚至可以禁止转身。这些参数放在 Data Asset 里,由动画状态机读取。

相机相对移动

魂类游戏的移动输入不是绝对世界方向,而是相对于相机。左摇杆前推代表朝镜头前方移动,左推代表朝镜头左侧移动。这种映射在 Hard Lock 下尤其重要,因为镜头始终盯着敌人,玩家只需要关心前后左右四个基础方向。

UE5.8 的 Enhanced Input 已经支持把 World Direction 改成 Camera Relative Direction。核心思路是把输入向量先投影到相机坐标系:

vinput=(xinput,yinput,0)\mathbf{v}_{input} = (x_{input}, y_{input}, 0)

vcamera=Rcamera1vinput\mathbf{v}_{camera} = R_{camera}^{-1} \cdot \mathbf{v}_{input}

其中 RcameraR_{camera} 是相机的旋转矩阵。更常见的写法是用 FRotationMatrix

const FRotator YawRotation(0.0f, Controller->GetControlRotation().Yaw, 0.0f);
const FVector Forward = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
const FVector Right = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
const FVector WorldDirection = Forward * InputY + Right * InputX;

上面的代码把输入向量从相机空间转换到世界空间,然后传给 AddMovementInput。在锁定状态下,ControlRotation 已经被目标锁定系统接管,所以相机 Yaw 总是指向敌人,玩家推动摇杆得到的 WorldDirection 自然就是相对敌人的方向。

这里有个细节:俯仰角不应该影响移动。所以只取 Yaw 旋转,忽略 Pitch 和 Roll。否则玩家在斜坡或仰视时,前推会变成往上爬。

目标丢失与重新锁定

战斗中目标不可能永远待在视野里。敌人可能瞬移、被建筑遮挡、跑出镜头范围,或者玩家自己翻滚到柱子后面。系统必须区分三种情况:临时遮挡、真正丢失、目标死亡。

我设计了一个状态机,核心状态包括 Locked、Occluded、Search、Idle。状态转移通过计时器和可见性检测触发。

sequenceDiagram
    participant P as 玩家
    participant L as 锁定控制器
    participant V as 可见性检测
    participant T as 目标
    participant C as 相机

    P->>L: 按下锁定键
    L->>V: 请求评分最高的目标
    V-->>L: 返回目标 T
    L->>T: 建立锁定
    T->>V: 持续可见
    T->>T: 瞬移到柱子后方
    T->>V: 不可见
    V->>L: 报告遮挡
    L->>L: 启动遮挡计时器
    Note over L: 保留原目标,相机缓慢复位
    T->>V: 重新可见
    V->>L: 取消遮挡
    T->>T: 跑出最大距离
    V->>L: 目标超出范围
    L->>L: 进入 Search 状态
    L->>V: 在旧位置附近搜索
    V-->>L: 无候选目标
    L->>C: 相机恢复玩家控制
    L->>P: 发送锁定丢失事件

状态之间的关系也可以单独画成一张状态图:

graph LR
    A[Idle] -->|按下锁定| B[Locked]
    B -->|目标可见| B
    B -->|临时遮挡| C[Occluded]
    C -->|恢复可见| B
    C -->|遮挡超时| D[Search]
    B -->|超出距离或死亡| D
    D -->|找到新目标| B
    D -->|无候选| A

代码上,遮挡和丢失的判断要分开处理。遮挡时我通常会保留锁定关系 1.5 秒左右,给玩家一个追上去的机会。丢失则是目标进入不可锁定状态,比如死亡、销毁、离开关卡,这种情况下立即清理引用,避免野指针。

void ULockOnComponent::Tick(float DeltaTime)
{
    if (!IsValid(CurrentTarget)) { SetState(ELockState::Idle); return; }

    const bool bVisible = CheckVisibility(CurrentTarget);
    const float Dist = GetDistanceTo(CurrentTarget);

    if (Dist > MaxLockDistance || !CurrentTarget->IsLockable())
    {
        SetState(ELockState::Search);
    }
    else if (!bVisible)
    {
        OcclusionTimer += DeltaTime;
        if (OcclusionTimer > MaxOcclusionTime)
        {
            SetState(ELockState::Search);
        }
    }
    else
    {
        OcclusionTimer = 0.0f;
    }
}

多目标切换

一场战斗经常出现多个敌人。单按锁定键切换最近目标已经不够用了,玩家需要明确选择左或右的敌人。我的做法是给 Enhanced Input 增加两个 Action:LockOnNextLeft 和 LockOnNextRight。按下后,系统以当前目标为基准,在水平面上找角度差最接近 90-90^\circ+90+90^\circ 的下一个候选。

切换算法的关键是计算相对角度。假设当前目标的方位角是 θcurrent\theta_{current},候选目标的方位角是 θi\theta_i,则相对角度:

\Delta \theta_i = \operatorname{WrapAngle}(\theta_i - \theta_{current})

只考虑 Δθi|\Delta \theta_i| 大于最小切换角度的候选,避免选到同一个目标。向左切换选 Δθi\Delta \theta_i 最接近 90-90^\circ 的目标,向右切换选最接近 +90+90^\circ 的目标。

这个方案比按距离排序更直观。玩家在镜头左侧看到敌人,按左切过去,镜头立刻转过去,角色自动调整朝向。切换时我会加 0.15 秒左右的插值延迟,避免镜头抖动。

锁定状态下的动画与攻击方向

锁定对动画系统的影响比想象中大。角色处于 Hard Lock 时,跑动动画要区分前、后、左、右四个方向,不能只用普通的前向跑步混合。UE5.8 的 BlendSpace 可以按 Direction 和 Speed 做二维混合,Direction 范围 180-180^\circ+180+180^\circ,Speed 取移动速度。

攻击方向同样要重新设计。普通状态下玩家按攻击键,角色朝当前朝向出招。锁定状态下,攻击方向应该指向敌人,即使玩家推动摇杆。我通常在攻击动画播放前做一个短时间的朝向修正:根据目标位置重新计算 Root Motion 方向,并把 IK 目标设为敌人躯干。

这里用到了一个简单的向量投影:

vattack=vinputdtarget+vinputntargetv_{attack} = \mathbf{v}_{input} \cdot \mathbf{d}_{target} + \mathbf{v}_{input} \cdot \mathbf{n}_{target}

其中 dtarget\mathbf{d}_{target} 是指向目标的单位向量,ntarget\mathbf{n}_{target} 是与它垂直的向量。这个点积结果可以区分前刺、横扫、背身攻击三种动画。

更深一层,Gameplay Ability System 的 Target Data 可以从锁定目标直接生成。Ability 不必再自己做球形检测,直接从 LockOnComponent 读取 CurrentTarget,把 Effect 发送过去。这简化了技能设计,也保证远程技能不会打偏。

网络同步

多人模式下,锁定状态必须同步。我采用了一个简单的规则:锁定目标由本地客户端决定,朝向和位置通过角色移动组件的常规复制同步。服务器只需要知道玩家是否处于锁定状态,以及当前锁定的是谁,用于验证攻击范围和技能目标。

具体实现上,我在 PlayerState 或 Character 里加一个 AActor* ReplicatedTarget 变量,标记 ReplicatedUsing = OnRep_Target。当本地客户端切换目标时,向服务器发送一个可靠的 RPC,服务器校验目标合法后更新复制变量,其他客户端就能看到这个玩家的锁定对象。

朝向同步不需要额外 RPC。ControlRotation 本身在 CharacterMovementComponent 的网络平滑里已经同步。服务器上,AI 敌人也可以读取玩家的 ReplicatedTarget,调整仇恨或防御策略。

不过要注意,目标选择完全在本地做,不要把射线检测和评分算法搬到服务器跑。那样会浪费带宽,还会因为服务器没有客户端的精确相机数据而误判。服务器只负责校验结果。

调试与调优

这套系统上线前有几个常见坑需要排查。

镜头穿模是最直观的问题。Hard Lock 下 SpringArm 的 Probe Channel 要正确设置,Target Offset 的插值速度不能太快,否则镜头会在目标跳动时抽搐。我会给 SpringArm 加一个 Collision Test 的调试球,在 PIE 里看是否被墙壁夹住。

锁定抖动通常来自评分函数的权重不合理。如果距离分权重太高,玩家稍微后退就会换目标;如果角度分太敏感,镜头边缘的目标容易抢锁。调参时我会把三个分量的中间值打印在屏幕上,看哪个分量在不该波动的时候跳变。

多目标切换不跟手,多半是因为 WrapAngle 没处理好 180180^\circ 边界。UE 的 FMath::FindDeltaAngleDegrees 可以帮你正确计算两个角度之间的最短差值。

目标丢失后镜头甩得太快,一般是相机复位曲线太陡。我会给 SpringArm 的 Yaw 加一个阻尼,让镜头在锁定断开后半秒内缓慢回到玩家背后,而不是瞬间回正。

结语

目标锁定系统表面上只是一个 UI 白点,实际上贯穿输入处理、相机控制、角色移动、动画系统、技能系统和网络同步。UE5.8 提供了模块化的工具链,把它们拼好,重点在于把各模块的数据流向和状态边界理清楚,而不是堆一段复杂代码。

下一次你在游戏里按下锁定键,镜头稳稳地咬住 Boss,角色自动侧身闪避,左摇杆后推就能拉开距离,这背后是评分函数、插值转向、相机相对移动和网络复制在协同工作。把这些细节打磨好,战斗手感会从僵硬变得流畅。

实际项目里的几个边界情况

Demo 阶段锁定系统往往跑得不错,但真放到完整关卡里,边界情况会一个接一个冒出来。

第一个问题是死亡目标的清理。敌人被杀死后,如果逻辑里没把 CurrentTarget 置空,系统可能继续对一具尸体做转向和相机跟踪。我的做法是给可锁定对象加一个 IsLockable 接口,死亡时立即返回 false。LockOnComponent 在 Tick 里检测到这个变化后,立刻进入 Search 状态,不再访问已经失效的指针。

第二个问题是骑乘或载具。玩家骑上马后,角色胶囊体的高度和中心变了,射线检测的起点要从新的眼睛位置计算。如果你硬编码了站立时的高度,锁定会整体偏移,导致目标看起来在白点旁边。我会把视点偏移抽成一个接口函数,让角色、载具、坐骑各自实现。

第三个问题是过场动画和对话。过场中玩家不能操作,但锁定系统可能还在 Tick 里抢镜头。我加了一个全局的 bBlockLockOn 标志,由关卡序列或对话系统在播放时设置。标志为 true 时,锁定控制器跳过所有更新,相机交还给 Cinemachine 或过场系统。

第四个问题是平台差异。主机手柄的右摇杆死区和鼠标差很多。Hard Lock 下玩家推动右摇杆只是想微调俯仰,如果死区设得太低,轻微输入就会让相机上下抖动。我会把死区参数放到 Input Mapping Context 里,并给锁定状态下的俯仰单独一条曲线。

最后一个问题是性能。球形扫描每一帧都做没问题,但射线检测不能太多。我的做法是把可见性检测频率降到每秒 10 到 15 次,用计时器触发,而不是每帧触发。只有在切换目标或目标进入遮挡状态时才临时提高到每帧检测,保证响应速度。