JustMakeGame 17. 攀爬系统

我使用的动画资源。其实第一个就够

image.png
image.png

攀爬状态

image.png

image.png

  • 打开角色运动组件,里面有一个默认陆地行走模式。改成custom自定义,然后再配合空的动画蓝图。运行后会发现角色动不了了。因为这里没有任何流程代码。
  • 我们可以认为这个Custom状态就是现在的攀爬状态。

所以我们需要一个接口,进入和退出攀爬状态,就是设置上面的CharacterMovement 进入 MOVE_Custom 和 MOVE_Walking

void UCwlClimbComponent::StartClimb()
{
    SetClimbState(MOVE_Custom);
}

void UCwlClimbComponent::StopClimb()
{
    SetClimbState(MOVE_Walking);
}

void UCwlClimbComponent::SetClimbState(EMovementMode InMode)
{
    if (!IsComponentReady())
    {
        return;
    }
    OwnerMovement->SetMovementMode(InMode);
}

同时,角色运动发生变化时候会回调OnMovementModeChanged,修改我们自己维护一个变量运动状态为攀爬,作为动画切换的条件

void ACwlCharacter::OnMovementModeChanged(EMovementMode PrevMovementMode, uint8 PreviousCustomMode)
{
    Super::OnMovementModeChanged(PrevMovementMode, PreviousCustomMode);

    if (EMovementMode MovementMode = this->GetCharacterMovement()->MovementMode.GetValue();
        MovementMode == EMovementMode::MOVE_None || MovementMode == EMovementMode::MOVE_Walking || MovementMode == EMovementMode::MOVE_NavWalking)
    {
        SetMovementState(ECwlMovementState::Grounded);
    }

    // ...

    else if (MovementMode == EMovementMode::MOVE_Custom)
    {
        SetMovementState(ECwlMovementState::Climbing);
    }
}

并且我把攀爬状态塞在了ALSv4的MainMovementState这一层,右下角

image.png

射线检测进入攀爬状态

如何进入攀爬状态,可以敲上面的函数测试。一般都是射线检测,看过很多个版本,怎么写的都有,能用就行。
一种方案是,在组件tick里面进行三条射线检测。

    FHitResult HitResultHighLevel, HitResultLowLevel, HitResultGround;
    float HighLevelTraceDistance = LineTraceForwardHighLevel(HitResultHighLevel);  // 头顶
    float LowLevelTraceDistance = LineTraceForwardLowLevel(HitResultLowLevel);  // 腿部
    float GroundTraceDistance = LineTraceDownFromFoot(HitResultGround);
    const float ClimbTraceDistance = OwnerCapsuleComponent->GetScaledCapsuleRadius() * 1.5f;

image.png

  • 头顶一条向前的射线,膝盖位置一条向前的射线。用于检测墙面。
    • 如果膝盖检测到了,头顶没有检测到。那就是爬到顶了。
    • 如果头顶检测到,膝盖没检测到,下面没得东西爬。
  • 角色向下一条射线
    • 如果在很短的距离内检测到了地面,那就是要落地了。

于是我们写出这样的代码 tick 中
ECwlClimbState 有 Climb 和 None 两个值,用于攀爬状态中的多种子状态的描述

void UCwlClimbComponent::UpdateClimbState(float Deltatime)
{
    if (!IsComponentReady())
    {
        return;
    }

    FHitResult HitResultHighLevel, HitResultLowLevel, HitResultGround;
    float HighLevelTraceDistance = LineTraceForwardHighLevel(HitResultHighLevel);  // 头顶
    float LowLevelTraceDistance = LineTraceForwardLowLevel(HitResultLowLevel);  // 腿部
    float GroundTraceDistance = LineTraceDownFromFoot(HitResultGround);
    const float ClimbTraceDistance = OwnerCapsuleComponent->GetScaledCapsuleRadius() * 1.5f;

    if (HitResultHighLevel.bBlockingHit && HitResultLowLevel.bBlockingHit)
    {
        if (LowLevelTraceDistance <= ClimbTraceDistance)
        {
            if (ClimbState == ECwlClimbState::Climb)
            {
                if (HitResultGround.bBlockingHit && GroundTraceDistance <= 2.f)
                {
                    StopClimb();
                }
            }
            else
            {
                ClimbState = ECwlClimbState::Climb;
                StartClimb();
            }
        }
    }
    else if (HitResultLowLevel.bBlockingHit && !HitResultHighLevel.bBlockingHit)
    {
        // 爬到顶了
    }
    else
    {
        if (ClimbState == ECwlClimbState::Climb)
        {
            ClimbState = ECwlClimbState::None;
            StopClimb();
        }
    }
}

攀爬输入

image.png

给攀爬状态机输出一个简单的动画。这样可以上墙了,但是发现移动不了。

处理输入

void ACwlBasePlayer::Move(const FInputActionValue& Value)
{
    FVector2D MovementVector = Value.Get<FVector2D>();
    if (Controller != nullptr)
    {
        // ...
        else if (GetMovementState() == ECwlMovementState::Climbing)
        {
            ClimbComponent->Move(OutForwardAxis, OutRightAxis);
        }
        // ...
    }
}

想象一下,你趴在墙上,WS控制的角色垂直上下,AD控制左右。调用到 AddMovementInput

void UCwlClimbComponent::Move(float ForwardAxis, float RightAxis)
{
    if (!IsComponentReady())
    {
        return;
    }

    const FRotator Rotation = OwnerCharacter->GetActorRotation();
    const FRotator ForwardYawRotation(Rotation.Pitch, Rotation.Yaw, 0.f);
    const FVector ForwardDirection = FRotationMatrix(ForwardYawRotation).GetUnitAxis(EAxis::Z);
    OwnerCharacter->AddMovementInput(ForwardDirection, ForwardAxis);

    const FRotator RightYawRotation(0, Rotation.Yaw, 0);
    const FVector RightDirection = FRotationMatrix(RightYawRotation).GetUnitAxis(EAxis::Y);
    OwnerCharacter->AddMovementInput(RightDirection, RightAxis);
}

还是动不了

因为虚幻更新移动的PhysXXX 中,PhysCustom 没帮我们写呀

void UCharacterMovementComponent::StartNewPhysics(float deltaTime, int32 Iterations)
{
    if ((deltaTime < MIN_TICK_TIME) || (Iterations >= MaxSimulationIterations) || !HasValidData())
    {
        return;
    }

    if (UpdatedComponent->IsSimulatingPhysics())
    {
        UE_LOG(LogCharacterMovement, Log, TEXT("UCharacterMovementComponent::StartNewPhysics: UpdateComponent (%s) is simulating physics - aborting."), *UpdatedComponent->GetPathName());
        return;
    }

    const bool bSavedMovementInProgress = bMovementInProgress;
    bMovementInProgress = true;

    switch ( MovementMode )
    {
    case MOVE_None:
        break;
    case MOVE_Walking:
        PhysWalking(deltaTime, Iterations);
        break;
    case MOVE_NavWalking:
        PhysNavWalking(deltaTime, Iterations);
        break;
    case MOVE_Falling:
        PhysFalling(deltaTime, Iterations);
        break;
    case MOVE_Flying:
        PhysFlying(deltaTime, Iterations);
        break;
    case MOVE_Swimming:
        PhysSwimming(deltaTime, Iterations);
        break;
    case MOVE_Custom:
        PhysCustom(deltaTime, Iterations); // 《=====================================【这里】
        break;
    default:
        UE_LOG(LogCharacterMovement, Warning, TEXT("%s has unsupported movement mode %d"), *CharacterOwner->GetName(), int32(MovementMode));
        SetMovementMode(MOVE_None);
        break;
    }

    bMovementInProgress = bSavedMovementInProgress;
    if ( bDeferUpdateMoveComponent )
    {
        SetUpdatedComponent(DeferredUpdatedMoveComponent);
    }
}

完成 PhysCustom 函数。目标是调用 SafeMoveUpdatedComponent 进行角色移动设置

void UCwlMovement::PhysCustom(float DeltaTime, int32 Iterations)
{
    Super::PhysCustom(DeltaTime, Iterations);
    PhysClimbing(DeltaTime, Iterations);
}

void UCwlMovement::PhysClimbing(float DeltaTime, int32 Iterations)
{
    if (DeltaTime < MIN_TICK_TIME || GetOwnerRole() == ROLE_SimulatedProxy)
    {
        return;
    }

    Velocity = GetLastInputVector() * MaxCustomMovementSpeed;
    const FVector Adjusted = Velocity * DeltaTime;
    FHitResult Hit(1.f);
    SafeMoveUpdatedComponent(Adjusted, UpdatedComponent->GetComponentQuat(), true, Hit);
}

然后就能动了。

墙面贴合

image.png

趴在墙上的时候,没有修正角色朝向。如果这样角色向右挪一下就出去了。在tick的最后

void UCwlClimbComponent::PostUpdateClimbRotation(float Deltatime, const FHitResult& HitResultLowLevel)
{
    // 墙面吸附问题
    if (HitResultLowLevel.bBlockingHit)
    {
        if (ClimbState == ECwlClimbState::Climb)
        {
            FRotator ActorRotation = OwnerCharacter->GetActorRotation();
            FRotator NewRot = FRotationMatrix::MakeFromX(OwnerCharacter->GetActorForwardVector() - HitResultLowLevel.Normal).Rotator();
            ActorRotation.Yaw = NewRot.Yaw;
            ActorRotation.Pitch = NewRot.Pitch;
            ActorRotation.Roll = 0.f;
            OwnerCharacter->SetActorRotation(FMath::RInterpTo(OwnerCharacter->GetActorRotation(), ActorRotation, Deltatime, 10.f));
        }
    }
}

攀爬循环动画

image.png

这个混合空间是一个各个方向攀爬动作的混合。
image.png

void UClimbAnimIns::CalculateClimbAxis(float InMaxSpeed)
{
    ACwlCharacter* OwnerCharacter = Cast<ACwlCharacter>(TryGetPawnOwner());
    if (OwnerCharacter == nullptr)
    {
        return;
    }

    FVector SpeedVector = OwnerCharacter->GetVelocity();
    FRotator CharacterRot = OwnerCharacter->GetActorRotation();

    SpeedVector = CharacterRot.UnrotateVector(SpeedVector);
    AxisSpeed.X = FMath::GetMappedRangeValueClamped(FVector2D(-InMaxSpeed, InMaxSpeed), FVector2D(-1.f, 1.f), SpeedVector.X);
    AxisSpeed.Y = FMath::GetMappedRangeValueClamped(FVector2D(-InMaxSpeed, InMaxSpeed), FVector2D(-1.f, 1.f), SpeedVector.Y);
    AxisSpeed.Z = FMath::GetMappedRangeValueClamped(FVector2D(-InMaxSpeed, InMaxSpeed), FVector2D(-1.f, 1.f), SpeedVector.Z);
}

根据速度向量,去掉角色旋转后的XYZ作为混合空间输入。速度上下限是MaxCustomMovementSpeed

ALSv4的攀爬

image.png

接下来处理的是翻过障碍物,我们爬到顶上也可以用这个。显然ALSv4的细节比较好,直接用这个。
这里有个60是因为走斜坡的时候会一直触发爬上。简单处理

    else if (HitResultLowLevel.bBlockingHit && !HitResultHighLevel.bBlockingHit)
    {
        // 爬到顶了,或者翻过障碍物
        if (LowLevelTraceDistance <= 60.f) // 避免斜坡出问题
        {
            MantleCheck(GroundedTraceSettings, EDrawDebugTrace::Type::ForOneFrame); 
        }
    }

ALS的攀爬指的是翻过障碍物,包括两个流程,检测和执行。和原版本的不同,我把检测接入到这里,并修改了他的三次检测流程。

image.png

角色前方向下一次检测。寻找翻阅点。

void UCwlClimbComponent::MantleCheck(FCwlMantleTraceSettings TraceSettings, EDrawDebugTrace::Type DebugType)
{
    if (!IsComponentReady())
    {
        return;
    }

    // 【1】在角色前方从上向下打射线,找攀爬点
    FHitResult HitResultWallDown;
    FVector StartTraceLocation = OwnerCharacter->GetActorLocation() + OwnerCharacter->GetActorForwardVector() * 50.f;
    StartTraceLocation.Z += OwnerCapsuleComponent->GetScaledCapsuleHalfHeight();
    FVector EndTraceLocation = StartTraceLocation - OwnerCharacter->GetActorUpVector() * OwnerCapsuleComponent->GetScaledCapsuleHalfHeight() * 2.f;
    TArray<AActor*> ClimbingActorsToIgnore;
    UKismetSystemLibrary::LineTraceSingle(
        GetWorld(),
        StartTraceLocation,
        EndTraceLocation,
        ETraceTypeQuery::TraceTypeQuery1,
        false,
        ClimbingActorsToIgnore,
        DrawDebugTrace, HitResultWallDown, true);

    if (HitResultWallDown.bBlockingHit)
    {
        HitResultWallDown.Location.Z += OwnerCapsuleComponent->GetScaledCapsuleHalfHeight() + 2.f;
        ClimbingTracePoint = HitResultWallDown.Location;
        float MantleHeight = OwnerCapsuleComponent->GetScaledCapsuleHalfHeight() * 2.f - HitResultWallDown.Distance;

        ECwlMantleType MantleType = ECwlMantleType::LowMantle;
        if (MantleHeight > OwnerCapsuleComponent->GetScaledCapsuleHalfHeight() * 1.5f)
        {
            MantleType = ECwlMantleType::HighMantle;
        }
        else
        {
            MantleType = ECwlMantleType::LowMantle;
        }

        const FVector DownTraceLocation(HitResultWallDown.Location.X, HitResultWallDown.Location.Y, HitResultWallDown.ImpactPoint.Z);
        const FVector& CapsuleLocationFBase = GetCapsuleLocationFromBase(DownTraceLocation, 2.0f);
        const FTransform TargetTransform(OwnerCharacter->GetActorRotation(), CapsuleLocationFBase);

        FCwlComponentAndTransform MantleWS;
        MantleWS.Component = HitResultWallDown.GetComponent();
        MantleWS.Transform = TargetTransform;
        MantleStart(MantleHeight, MantleWS, MantleType);
    }
}

原版这里还多一次射线检测,这个地方是否能站一个人,我这里先不写
计算获得,攀爬高度,判断是高位攀爬,还是低位攀爬

image.png
image.png

开始MantleStart。这部分代码就抄ALSv4 C++里面的逻辑了

首先他有3个动画,基于此创建蒙太奇,1m低位攀爬左手,右手,和2m高位攀爬。

image.png

配表

image.png

Anim Montage 准备播放的动画
Position Correction Curve。攀爬曲线,用曲线位移来代替根运动,达到攀爬不同高度物体都能适配的效果。

图片来自 94高级运动系统-攀爬系统:执行_哔哩哔哩_bilibili

image.png

Start Offset 的含义是如果我们直接,我们按照左图的路线插值,可能会穿模,需要按右图的方式插值。

其余配置是,有一个低攀爬的高度,一个高攀爬的高度。这个构成一个区间。当前攀爬的高度落在这个区间内。
又根据,Play Rate,Start Positon 区间,映射一个我们动画播放的速率。

    // Step 1: 根据配置计算参数
    const FCwlMantleAsset MantleAsset = GetMantleAsset(MantleType);
    check(MantleAsset.PositionCorrectionCurve)

    MantleParams.AnimMontage = MantleAsset.AnimMontage;
    MantleParams.PositionCorrectionCurve = MantleAsset.PositionCorrectionCurve;
    MantleParams.StartingOffset = MantleAsset.StartingOffset;

    MantleParams.StartingPosition = FMath::GetMappedRangeValueClamped<float, float>( // 攀爬动画播放的开始位置
        {MantleAsset.LowHeight, MantleAsset.HighHeight},
        {MantleAsset.LowStartPosition,MantleAsset.HighStartPosition}, MantleHeight);

    MantleParams.PlayRate = FMath::GetMappedRangeValueClamped<float, float>( // 攀爬动画播放的速率
        {MantleAsset.LowHeight, MantleAsset.HighHeight},
        {MantleAsset.LowPlayRate, MantleAsset.HighPlayRate}, MantleHeight);
    // Step 2: 世界坐标转局部坐标
    // 为了支持可移动的物体的攀爬(你爬的墙跑路了),
    MantleLedgeLS.Component = MantleWS.Component;
    MantleLedgeLS.Transform = MantleWS.Transform * MantleWS.Component->GetComponentToWorld().Inverse();
    // Step 3: 计算攀爬目标和起始点的距离
    MantleTarget = MantleWS.Transform; // 攀爬后,角色的Transform
    MantleActualStartOffset = UCwlMathLibrary::TransformSub(OwnerCharacter->GetActorTransform(), MantleTarget);

这里的 MantleActualStartOffse 就是黄色这一条, 角色减目标

image.png

    // Step 4: 计算动画开始位置和目标的插值. 播放动作的时候防止穿模算的一个目标变换
    FVector RotatedVector = MantleTarget.GetRotation().GetForwardVector() * MantleParams.StartingOffset.Y;
    RotatedVector.Z = MantleParams.StartingOffset.Z;
    const FTransform StartOffset(MantleTarget.Rotator(), MantleTarget.GetLocation() - RotatedVector, FVector::OneVector);
    MantleAnimatedStartOffset = UCwlMathLibrary::TransformSub(StartOffset, MantleTarget);

image.png
image.png

这里算的是红色

    // Step 5: 修改移动状态
    OwnerCharacter->GetCharacterMovement()->SetMovementMode(MOVE_None);
    OwnerCharacter->SetMovementState(ECwlMovementState::Mantling);

    // Step 6: Timeline 与插值
    float MinTime = 0.0f;
    float MaxTime = 0.0f;
    MantleParams.PositionCorrectionCurve->GetTimeRange(MinTime, MaxTime);
    MantleTimeline->SetTimelineLength(MaxTime - MantleParams.StartingPosition);
    MantleTimeline->SetPlayRate(MantleParams.PlayRate);
    MantleTimeline->PlayFromStart();

    if (MantleParams.AnimMontage && OwnerCharacter->GetMesh()->GetAnimInstance())
    {
        OwnerCharacter->GetMesh()->GetAnimInstance()->Montage_Play(
            MantleParams.AnimMontage, MantleParams.PlayRate, EMontagePlayReturnType::MontageLength, MantleParams.StartingPosition, false);
    }

    return true;

然后是修改移动状态,然后开启timeline。
这个timeline在构造函数构造,然后绑定,执行的时候调用 MantleUpdate,结束的时候调用 MantleEnd,并且还有一条配置的曲线 MantleTimelineCurve

const FName NAME_MantleEnd(TEXT("MantleEnd"));
const FName NAME_MantleUpdate(TEXT("MantleUpdate"));
const FName NAME_MantleTimeline(TEXT("MantleTimeline"));

UCwlClimbComponent::UCwlClimbComponent()
    : Super(),
      ClimbState(ECwlClimbState::None)
{
    PrimaryComponentTick.bCanEverTick = true;
    MantleTimeline = CreateDefaultSubobject<UTimelineComponent>(NAME_MantleTimeline);
    GroundedTraceSettings.InitGroundedTraceSettingsPreset();
    FallingTraceSettings.InitFallingTraceSettingsPreset();
}

void UCwlClimbComponent::BeginPlay()
{
    Super::BeginPlay();

    if (OwnerMovement.IsValid())
    {
        OwnerMovement->MaxCustomMovementSpeed = 200.f;
    }

    FOnTimelineFloat TimelineUpdated;
    FOnTimelineEvent TimelineFinished;
    TimelineUpdated.BindUFunction(this, NAME_MantleUpdate);
    TimelineFinished.BindUFunction(this, NAME_MantleEnd);
    MantleTimeline->SetTimelineFinishedFunc(TimelineFinished);
    MantleTimeline->SetLooping(false);
    MantleTimeline->SetTimelineLengthMode(TL_TimelineLength);
    MantleTimeline->AddInterpFloat(MantleTimelineCurve, TimelineUpdated);
}

image.png
0~0.2

然后我们看Update和 End
其中end比较简单,设置回walking状态就好。

void UCwlClimbComponent::MantleEnd()
{
    if (IsComponentReady())
    {
        OwnerCharacter->GetCharacterMovement()->SetMovementMode(MOVE_Walking);
    }

    SetComponentTickEnabledAsync(true);
}

然后是update

    // Step 1: 局部坐标转换世界坐标,处理那种墙跑了的情况。
    MantleTarget = UCwlMathLibrary::MantleComponentLocalToWorld(MantleLedgeLS);
    // Step 2: 使用曲线更新攀爬过程中的唯一。
    const FVector CurveVec = MantleParams.PositionCorrectionCurve->GetVectorValue(MantleParams.StartingPosition + MantleTimeline->GetPlaybackPosition());
    const float PositionAlpha = CurveVec.X;
    const float XYCorrectionAlpha = CurveVec.Y;
    const float ZCorrectionAlpha = CurveVec.Z;

image.png

X曲线用来控制 0.3~1。表示这个动画根运动从0.3s开始,到1s运动到了目标位置

image.png

然后Z曲线表示是从0.3s开始,角色开始向上。同理Y是水平向的。

void UCwlClimbComponent::MantleUpdate(float BlendIn)
{
    if (!IsComponentReady())
    {
        return;
    }

    // Step 1: 局部坐标转换世界坐标,处理那种墙跑了的情况。
    MantleTarget = UCwlMathLibrary::MantleComponentLocalToWorld(MantleLedgeLS);

    // Step 2: 使用曲线更新攀爬过程中的位移,获取数据。
    const FVector CurveVec = MantleParams.PositionCorrectionCurve->GetVectorValue(MantleParams.StartingPosition + MantleTimeline->GetPlaybackPosition());
    const float PositionAlpha = CurveVec.X;
    const float XYCorrectionAlpha = CurveVec.Y;
    const float ZCorrectionAlpha = CurveVec.Z;

    // Step 3.1:水平方向上的插值
    const FTransform TargetHzTransform(
        MantleAnimatedStartOffset.GetRotation(),
        {MantleAnimatedStartOffset.GetLocation().X, MantleAnimatedStartOffset.GetLocation().Y, 0.f},
        FVector::OneVector);
    const FTransform& HzLerpResult = UKismetMathLibrary::TLerp(MantleActualStartOffset, TargetHzTransform, XYCorrectionAlpha);

    // Step 3.2 竖直方向上的插值
    const FTransform TargetVtTransform(
        MantleActualStartOffset.GetRotation(),
        {0.f, 0.f, MantleAnimatedStartOffset.GetLocation().Z},
        FVector::OneVector);
    const FTransform& VtLerpResult = UKismetMathLibrary::TLerp(MantleActualStartOffset, TargetVtTransform, ZCorrectionAlpha);

    // Step 3.3 混合水平和竖直方向上的插值
    const FTransform ResultTransform(
        HzLerpResult.GetRotation(),
        {HzLerpResult.GetLocation().X, HzLerpResult.GetLocation().Y, VtLerpResult.GetLocation().Z },
        FVector::OneVector);

    // Step 3.4 相对变换+目标变换,获得最终的变换。然后向 Target插值
    const FTransform& ResultLerp = UKismetMathLibrary::TLerp(UCwlMathLibrary::TransformAdd(MantleTarget, ResultTransform), MantleTarget, PositionAlpha);

    // ...
}

image.png

这一段需要一个过渡。用到上面0.2s的曲线

    // Step 4 初始位置插值。
    const FTransform& LerpedTarget = UKismetMathLibrary::TLerp(UCwlMathLibrary::TransformAdd(MantleTarget, MantleActualStartOffset), ResultLerp, BlendIn);

    // Step 5: 更新角色的位置和旋转
    OwnerCharacter->SetActorLocationAndTargetRotation(LerpedTarget.GetLocation(), LerpedTarget.GetRotation().Rotator());

手部IK

参考资料:

上一篇
下一篇