战斗中的镜头:一次失败的 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目标选择:距离、角度与可见性
锁定第一步是找出玩家想打谁。最朴素的办法是遍历一定半径内的所有敌人,按距离排序选最近。这在开阔场地没问题,可一旦场景里有柱子、墙壁、其他敌人,单纯距离会给出荒谬结果。玩家明明看着正前方的敌人,系统却锁定到背后三米外的骷髅兵。
更好的做法是设计一个评分函数,把距离、角度、可见性加权求和:
其中 是角色到目标的距离, 是归一化距离分; 是目标相对于角色正前方的水平夹角, 是角度分; 是可见性布尔值或可见比例, 是可见性分。三个权重 、、 可以在蓝图中按职业或武器调整。
距离分的计算需要处理太近和太远两种情况。我倾向于用高斯衰减:
是理想锁定距离,比如 3.5 米; 控制衰减速度。目标贴脸时分数不会断崖式下跌,太远时也不会出现负分。
角度分用余弦函数最直观:
当目标在正前方时 ,;在正后方时 ,。这个值可以直接和距离分相乘,保证玩家不会意外锁定到背后的敌人。
可见性用射线检测。从角色眼睛位置向目标中心、头部、胸部各打一条射线,只要有一条没有被 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,角色朝向可以这样计算:
目标偏航角 由 在水平面上的分量决定:
\phi = \operatorname{atan2}(d_y, d_x)当前偏航角 与目标偏航角之间的差值要限制在最大转速以内:
\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。核心思路是把输入向量先投影到相机坐标系:
其中 是相机的旋转矩阵。更常见的写法是用 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。按下后,系统以当前目标为基准,在水平面上找角度差最接近 或 的下一个候选。
切换算法的关键是计算相对角度。假设当前目标的方位角是 ,候选目标的方位角是 ,则相对角度:
\Delta \theta_i = \operatorname{WrapAngle}(\theta_i - \theta_{current})只考虑 大于最小切换角度的候选,避免选到同一个目标。向左切换选 最接近 的目标,向右切换选最接近 的目标。
这个方案比按距离排序更直观。玩家在镜头左侧看到敌人,按左切过去,镜头立刻转过去,角色自动调整朝向。切换时我会加 0.15 秒左右的插值延迟,避免镜头抖动。
锁定状态下的动画与攻击方向
锁定对动画系统的影响比想象中大。角色处于 Hard Lock 时,跑动动画要区分前、后、左、右四个方向,不能只用普通的前向跑步混合。UE5.8 的 BlendSpace 可以按 Direction 和 Speed 做二维混合,Direction 范围 到 ,Speed 取移动速度。
攻击方向同样要重新设计。普通状态下玩家按攻击键,角色朝当前朝向出招。锁定状态下,攻击方向应该指向敌人,即使玩家推动摇杆。我通常在攻击动画播放前做一个短时间的朝向修正:根据目标位置重新计算 Root Motion 方向,并把 IK 目标设为敌人躯干。
这里用到了一个简单的向量投影:
其中 是指向目标的单位向量, 是与它垂直的向量。这个点积结果可以区分前刺、横扫、背身攻击三种动画。
更深一层,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 没处理好 边界。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 次,用计时器触发,而不是每帧触发。只有在切换目标或目标进入遮挡状态时才临时提高到每帧检测,保证响应速度。