JustMakeGame 1. 初始化项目&增强输入系统&基于状态机的摄像机

开篇

"task is cheap show me the code"
仓库地址:https://gitee.com/weilin3101/JustMakeGame 【笔记开源,项目不开源】

这是一个我自己的项目的运动系统部分笔记。预计包括基础运动Locotmoion和,其他运动比如攀爬,飞行等。
这个项目重构了我的上一次 ALSv4的复刻项目。过一遍全部功能修一遍bug,把上一个版本的开发流程全记录笔记整理了一下。形成了这个系列笔记。

参考:
Advance Locomotion System V4
ALSv4 C++
Lyra
Advance Locomotion Component
TNT Flexible Locomotion

第一篇,从新建项目开始,初始化项目架构,构建一个最基础版本的角色控制功能。然后再细化了移动,视角控制,和摄像机系统。

初始化项目

首先下载ALSv4原版 https://www.unrealengine.com/marketplace/en-US/product/advanced-locomotion-system-v1 ,把Content目录挪过来。不过我们只是用他的动画资源。
新建第三人称项目(这样可以白嫖一个创建好的IK Rig,方便重定向动画),首先按照虚幻的Gameplay架构创建一堆空的类

  • 首先有一个角色类Character,和对应的动画蓝图实例UAnimInstance。同时我希望把输入操作挪到角色的子类里面。基类可以被继承出AICharacter(这个以后再说)。所以有 ACwlCharacter,ACwlBasePlayer,UCwlBaseAnimIns 三个类
  • 需要一个GameMode类(ACwlGameMode),来设置Pawn 和 PlayerController。然后PlayerController中关联 PlayerCameraManager
  • 创建必要的类,直到运行项目后屏幕中心出现一个T-Pose的角色。什么都动不了。

我们需要弄出一个基础的版本,需要包含WASD运动,鼠标控制视角朝向。然后再一点点优化细节
先用蓝图写,为了有一个基础能看的版本,后续会把尽可能多的逻辑丢到C++中。

  • 在角色中,首先添加弹簧臂和摄像机,注意勾选使用Pawn控制旋转

image.png

  • 然后点这里

image.png

右侧属性面板,取消使用控制器旋转,勾选将旋转朝向运动。
image.png

在PlayerController中设置运动和视角变化的输入。这里网络的判断是DS模式下需要的,正常单机模式不需要
image.png

然后是移动输入按键触发输入事件
image.png

视角输入
image.png

然后处理动画状态机

首先有一个空的动画蓝图,我觉得第一步应该是弄出这个两个状态,Moving放一个奔跑动画,Not Moving放一个IDLE动画。切换条件是是否有加速度,然后同步到动画蓝图里面

image.png

然后直接输出

image.png

如果是角色朝向速度方向的那种旋转方式,也就是勾选将旋转朝向运动。并且我希望移动到静止慢一点,这里混合设置调为0.5,默认是0.2。

image.png

效果还不错,如果这个是一个小兵,或者是不怎么关注角色运动的手游,或者你建项目是为了测试其他东西 Locomotion部分就可以完结撒花了。

增强输入系统

开始一步一步改细节。
首先基础知识,增强输入系统。这是UE5时代的按键输入绑定方式
https://www.yuque.com/chenweilin-tryw7/hretib/bta073607vzmnx5s?singleDoc# 《UE 增强输入系统》

勾选插件 Enhanced Input 增强输入系统。Build.cs中添加

PrivateDependencyModuleNames.AddRange(new string[] { "EnhancedInput" });

然后开始创建相关按键。由于公网的GIT仓库大小受限,除了Content/Blurprint以外的资产都加入了 .gitignore。
这里输入相关创建在Blurprint/Input下面
在InputAction中创建几个 Actions。

image.png

Input目录下Input Context Mapping

image.png

然后在Input Mapping Context 中设置按键。
大致包括鼠标右键瞄准,空格跳跃,鼠标视角控制,WASD移动,C蹲下,B键切换左右肩视角,R键翻滚,ALT切换步态(走还是跑),V切换第一人称/第三人称。鼠标左键攻击。
除了左键攻击以外,都是ALSv4中有的内容。

然后开始写代码,在ACwlBasePlayer中定义按键,用于直接在角色蓝图中配置关联上述创建

UCLASS()
class JUSTMAKEGAME_API ACwlBasePlayer : public ACwlCharacter
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputMappingContext* DefaultMappingContext;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* JumpAction;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* MoveAction;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* LookAction;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* StanceAction;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* WalkAction;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cwl|Input", meta = (AllowPrivateAccess = "true"))
    class UInputAction* SprintAction;

    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Cwl|Input", meta=(AllowPrivateAccess="true"))
    class UInputAction* AnimingAction;

    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Cwl|Input", meta=(AllowPrivateAccess="true"))
    class UInputAction* RollAction;

    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Cwl|Input", meta=(AllowPrivateAccess="true"))
    class UInputAction* ViewModeAction;

    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Cwl|Input", meta=(AllowPrivateAccess="true"))
    class UInputAction* RightShoulderAction;

    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Cwl|Input", meta=(AllowPrivateAccess="true"))
    class UInputAction* NormalAttackAction;

    // ...

重写 SetupPlayerInputComponent 方法,为按键写回调。

void ACwlBasePlayer::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    if (UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(PlayerInputComponent))
    {
        EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ACwlBasePlayer::Move);
        EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &ACwlBasePlayer::Look);
        EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputBeginJump);
        EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACwlBasePlayer::InputEndJump);
        EnhancedInputComponent->BindAction(StanceAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputChangeStanceAction);
        EnhancedInputComponent->BindAction(WalkAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputBeginWalk);
        EnhancedInputComponent->BindAction(WalkAction, ETriggerEvent::Completed, this, &ACwlBasePlayer::InputEndWalk);
        EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputBeginSprint);
        EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &ACwlBasePlayer::InputEndSprint);
        EnhancedInputComponent->BindAction(AnimingAction, ETriggerEvent::Started, this, &ACwlBasePlayer::OnClickAniming);
        EnhancedInputComponent->BindAction(AnimingAction, ETriggerEvent::Completed, this, &ACwlBasePlayer::OnReleasedAniming);
        EnhancedInputComponent->BindAction(RollAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputBeginRoll);
        EnhancedInputComponent->BindAction(ViewModeAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputViewModeAction);
        EnhancedInputComponent->BindAction(RightShoulderAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputRightShoulderAction);
        EnhancedInputComponent->BindAction(NormalAttackAction, ETriggerEvent::Started, this, &ACwlBasePlayer::InputNormalAttackPress);
        EnhancedInputComponent->BindAction(NormalAttackAction, ETriggerEvent::Completed, this, &ACwlBasePlayer::InputNormalAttackReleased);
    }
}

举例说明几个特色的

角色移动

void ACwlBasePlayer::Move(const FInputActionValue& Value)
{
    FVector2D MovementVector = Value.Get<FVector2D>();
    if (Controller != nullptr)
    {
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);
        const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
        float OutForwardAxis = 0.f;
        float OutRightAxis = 0.f;
        FixDiagonalClampedValues(MovementVector.Y, MovementVector.X, OutForwardAxis, OutRightAxis);

        if (GetMovementState() == ECwlMovementState::Fly)
        {
            FlyComponent->Move(OutForwardAxis, OutRightAxis);
        }
        else if (GetMovementState() == ECwlMovementState::Swimming)
        {
            // TODO
        }
        else if (GetMovementState() == ECwlMovementState::Climbing)
        {
            ClimbComponent->Move(OutForwardAxis, OutRightAxis);
        }
        else
        {
            AddMovementInput(ForwardDirection, OutForwardAxis);
            AddMovementInput(RightDirection, OutRightAxis); 
        }
    }
}

这个其实就是WASD的移动回调,最后能得到一个Vector2D。我们只需要最终调用到 AddMovementInput 角色即可移动。

一个比较特殊的细节是,可以认为同时按下前进和左移的时候,前进的输入值能对左方向的输入有0.2的加成。造成一种同时按左前,会比简单按前更快
ps: ALSv4的逻辑,不过我感觉不怎么明显。。。

void ACwlBasePlayer::FixDiagonalClampedValues(float InForwardAxis, float InRightAxis, float& OutForwardAxis, float& OutRightAxis)
{
    float EffectRight = UKismetMathLibrary::MapRangeClamped(FMath::Abs(InRightAxis), 0.f, 0.6f, 1.f, 1.2f);
    OutForwardAxis = FMath::Clamp(InForwardAxis * EffectRight, -1.f, 1.f);
    float EffectForward = UKismetMathLibrary::MapRangeClamped(FMath::Abs(InForwardAxis), 0.f, 0.6f, 1.f, 1.2f);
    OutRightAxis = FMath::Clamp(InRightAxis * EffectForward, -1.f, 1.f);
}

然后就是可以根据不同的运动状态对于输入有不同的处理。
ALSv4的移动状态有,地面,空中(包括跳跃和下落),攀爬(其实这个应该叫爬上障碍物,不能趴在墙上的),布娃娃状态。
我扩展了飞行,游泳,攀爬(区别于ALSv4的攀爬,我希望弄一个塞尔达式能够停在墙上的运动状态)的状态。具体后面展开。目前阶段只走else部分的逻辑。

UENUM(BlueprintType)
enum class ECwlMovementState : uint8
{
    Grounded,
    InAir,
    Mantling,
    Ragdoll,
    Fly,
    Swimming,
    Climbing,
};

角色视角

void ACwlBasePlayer::Look(const FInputActionValue& Value)
{
    FVector2D LookAxisVector = Value.Get<FVector2D>();
    if (Controller != nullptr)
    {
        AddControllerYawInput(LookAxisVector.X * LookLeftRightRate);
        AddControllerPitchInput(LookAxisVector.Y * LookUpDownRate);
    }
}

基于状态机的摄像机系统

基础摄像机

image.png

比较多的资料都是这么干的。在角色蓝图里面添加一个弹簧臂和摄像机。然后改改参数,够用了。
ALSv4中不这么干。另一种方式是
可以选择在PlayerController 上关联 PlayerCameraManager 这个类。
image.png

然后实现虚函数

/*
 * Run On Server
 */
void ACwlBaseCameraManager::UpdateViewTargetInternal(FTViewTarget& OutVT, float DeltaTime)
{
    Super::UpdateViewTargetInternal(OutVT, DeltaTime);

    if (OutVT.Target)
    {
        if (OutVT.Target->IsA<ACwlBasePlayer>())
        {
            FVector OutLocation;
            FRotator OutRotation;
            float OutFOV;

            if (CustomCameraBehavior(DeltaTime, OutLocation, OutRotation, OutFOV))
            {
                OutVT.POV.Location = OutLocation;
                OutVT.POV.Rotation = OutRotation;
                OutVT.POV.FOV = OutFOV;
            }
            else
            {
                OutVT.Target->CalcCamera(DeltaTime, OutVT.POV);
            }
        }
        else
        {
            OutVT.Target->CalcCamera(DeltaTime, OutVT.POV);
        }
    }
}

如果是DS服务器模式下,这个函数会在服务器被回调
我们最后只需要把 FTViewTarget& OutVT 这个输出参数的 Location,Rotation,FOV 设置好,这个就是我们的摄像机参数。意图是能够对摄像机有更大的自由度。
比如我们把计算函数丢到这里 CustomCameraBehavior,这样写,就实现了一个在角色后面一点点的摄像机。

bool ACwlBaseCameraManager::CustomCameraBehavior(float DeltaTime, FVector& Location, FRotator& Rotation, float& FOV)
{
    // 基础测试相机代码
    Location = GetOwningPlayerController()->GetPawn()->GetActorLocation() + FVector(-150, 0, 200);
    Rotation = GetOwningPlayerController()->GetControlRotation();
    FOV = 90;
    return true;
}

基于状态机

UCLASS()
class JUSTMAKEGAME_API ACwlBaseCameraManager : public APlayerCameraManager
{
    GENERATED_BODY()
protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cwl|Reference")
    USkeletalMeshComponent* CameraBehavior = nullptr;

在CameraManager里面我们可以看到一个 SkeletalMesh。在蓝图中,我们会给他选定摄像机的骨骼,并绑定动画蓝图。摄像机当然是没有动画要播放的,但是我们可以用他的动画状态机。
这样有一个好处,比如角色有三种旋转模式。
Looking Direction 角色始终朝向摄像机方向,按左移动是面向前方的左侧步。第三人称射击游戏一般这样
Velocity Direction 按左方向,角色会旋转面向左边,并移动
Anim 就是常见的游戏中的瞄准状态。

image.png

我们可以获取角色的身上的属性。然后摄像机的状态机也一样会切换。我们只需要在不同的状态机中修改曲线值。
image.png

这样摄像机的Update函数就不需要如果处于这个状态,读取某个数值的逻辑。只需要统一读取Offset X Y Z 的曲线值即可。

完善摄像机系统

所有逻辑都在一个Update函数中

bool ACwlBaseCameraManager::CustomCameraBehavior(float DeltaTime, FVector& Location, FRotator& Rotation, float& FOV)
{
    // ...
}

有几个细节

  • 位置和旋转延迟

摄像机位移延迟,角色跑起来,摄像机没更上,停下来一会摄像机才到目标位置
摄像机旋转延迟,视角转动,摄像机延迟一会才旋转到目标位置。这个是必须的,位置延迟还没事,这个不延迟转快点直接晕了。
这个好处理,只要记得不要马上设置值,用Lerp插值过去就能直接实现这种效果。

  • 如果摄像机和角色之间有障碍物

从摄像机到角色之间做一个射线检测,如果有障碍物,移动摄像机到障碍物之前

其他直接看代码

bool ACwlBaseCameraManager::CustomCameraBehavior(float DeltaTime, FVector& Location, FRotator& Rotation, float& FOV)
{
    // 基础测试相机代码
    // float CameraOffsetZ = CameraBehavior->GetAnimInstance()->GetCurveValue(TEXT("CameraOffset_Z"));
    // Location = GetOwningPlayerController()->GetPawn()->GetActorLocation() + FVector(-150, 0, 200);
    // Rotation = GetOwningPlayerController()->GetControlRotation();
    // FOV = 90;
    // return true;

    if (!GetOwningPlayerController())
    {
        UE_LOG(LogTemp, Error, TEXT("CustomCameraBehavior Controller None"));
        return false;
    }

    APawn* ControlledPawn = nullptr;
    if (ControlledPawn = GetOwningPlayerController()->GetPawn(); !ControlledPawn)
    {
        UE_LOG(LogTemp, Error, TEXT("CustomCameraBehavior Controller Pawn None"));
        return false;
    }

    ACwlBasePlayer* Character = nullptr;
    if (Character = Cast<ACwlBasePlayer>(ControlledPawn); !Character)
    {
        UE_LOG(LogTemp, Error, TEXT("CustomCameraBehavior Character None"));
        return false;
    }

    // 【1】初始化变量
    PivotTarget = Character->Get3PPivotTarget(); // 这个就是 Character->GetActorLocation()
    Character->GetCameraParams(this->TpsFov, this->FpsFov, this->bRightShoulder);
    FPSTarget = Character->GetFPCameraTarget();

    /*
     * 这里有两个概念,一个是相机的位置延迟,一个是相机的旋转延迟。
     */

    // 【2】Pivot Rotation 计算目标旋转: 相机当前旋转 到 控制器当前旋转 平滑得出的一个旋转值。曲线RotationLagSpeed越小,旋转延迟越大
    TargetCameraRotation = UKismetMathLibrary::RInterpTo(GetCameraRotation(), GetOwningPlayerController()->GetControlRotation(),
                                                         DeltaTime, GetCameraBehaviorParam(TEXT("RotationLagSpeed")));

    // 【3】Pivot Location 轴心位置应用延迟 获得 平滑的轴心目标: 
    FVector PivotTargetVector = CalculateAxisIndependentLag(
        SmoothedPivotTarget.GetLocation(),
        PivotTarget.GetLocation(),
        TargetCameraRotation,
        UKismetMathLibrary::MakeVector(
            GetCameraBehaviorParam(TEXT("PivotLagSpeed_X")),
            GetCameraBehaviorParam(TEXT("PivotLagSpeed_Y")),
            GetCameraBehaviorParam(TEXT("PivotLagSpeed_Z"))),
        DeltaTime);

    SmoothedPivotTarget = UKismetMathLibrary::MakeTransform(PivotTargetVector, PivotTarget.GetRotation().Rotator());

    // 【4】轴心位置 应用局部轴心偏移。蹲伏状态 PivotOffset_Z 会缓缓下降
    PivotLocation = SmoothedPivotTarget.GetLocation() +
        GetCameraBehaviorParam(TEXT("PivotOffset_X")) * SmoothedPivotTarget.GetRotation().GetForwardVector() +
        GetCameraBehaviorParam(TEXT("PivotOffset_Y")) * SmoothedPivotTarget.GetRotation().GetRightVector() +
        GetCameraBehaviorParam(TEXT("PivotOffset_Z")) * SmoothedPivotTarget.GetRotation().GetUpVector();

    // 【5】计算相机的Location
    TargetCameraLocation = PivotLocation +
        GetCameraBehaviorParam(TEXT("CameraOffset_X")) * UKismetMathLibrary::GetForwardVector(TargetCameraRotation) +
        GetCameraBehaviorParam(TEXT("CameraOffset_Y")) * UKismetMathLibrary::GetRightVector(TargetCameraRotation) +
        GetCameraBehaviorParam(TEXT("CameraOffset_Z")) * UKismetMathLibrary::GetUpVector(TargetCameraRotation);

    // 【6】在角色和摄像机之间有一个障碍物,把摄像机挪到障碍物前面,能看得到角色的位置。
    if (GetWorld())
    {
        FHitResult HitResult;
        UKismetSystemLibrary::SphereTraceSingle(
            GetWorld(),
            Character->GetActorLocation(),
            TargetCameraLocation,
            10.f,
            ETraceTypeQuery::TraceTypeQuery1,
            false,
            {},
            EDrawDebugTrace::None,
            HitResult,
            true);
        if (HitResult.IsValidBlockingHit())
        {
            TargetCameraLocation = TargetCameraLocation + HitResult.Location - HitResult.TraceEnd;
        }
    }

    // 【7】第一人称权重
    double FirstPersonPercent = GetCameraBehaviorParam(TEXT("Weight_FirstPerson"));
    FTransform From = UKismetMathLibrary::MakeTransform(TargetCameraLocation, TargetCameraRotation);
    FTransform To = UKismetMathLibrary::MakeTransform(FPSTarget, TargetCameraRotation);
    FTransform Result = UKismetMathLibrary::TLerp(From, To, FirstPersonPercent);
    Location = Result.GetLocation();
    Rotation = Result.GetRotation().Rotator();
    FOV = UKismetMathLibrary::Lerp(TpsFov, FpsFov, FirstPersonPercent);

    return true;
}

float ACwlBaseCameraManager::GetCameraBehaviorParam(FName CurveName)
{
    if (UCwlCameraBehavior* AnimIns = Cast<UCwlCameraBehavior>(CameraBehavior->GetAnimInstance()))
    {
        // float CurveValue = AnimIns->GetCurveValue(CurveName);
        // UE_LOG(LogTemp, Error, TEXT("get curve %s %f"), *CurveName.ToString(), CurveValue)
        return AnimIns->GetCurveValue(CurveName);
    }
    return 0.f;
}

FVector ACwlBaseCameraManager::CalculateAxisIndependentLag(FVector CurrentLocation, FVector TargetLocation, FRotator CameraRotation, FVector LagSpeeds, float DeltaTime)
{
    // 轴无关的插值,先把摄像机的旋转去掉,然后再插值,然后再回复旋转

    // 只需要管Yaw的旋转
    FRotator CameraRotationYaw = UKismetMathLibrary::MakeRotator(0, 0, CameraRotation.Yaw);

    // 去掉旋转
    CurrentLocation = UKismetMathLibrary::Quat_UnrotateVector(CameraRotationYaw.Quaternion(), CurrentLocation);
    TargetLocation = UKismetMathLibrary::Quat_UnrotateVector(CameraRotationYaw.Quaternion(), TargetLocation);

    // 插值
    float X = UKismetMathLibrary::FInterpTo(CurrentLocation.X, TargetLocation.X, DeltaTime, LagSpeeds.X);
    float Y = UKismetMathLibrary::FInterpTo(CurrentLocation.Y, TargetLocation.Y, DeltaTime, LagSpeeds.Y);
    float Z = UKismetMathLibrary::FInterpTo(CurrentLocation.Z, TargetLocation.Z, DeltaTime, LagSpeeds.Z);

    // 旋转
    return UKismetMathLibrary::Quat_RotateVector(CameraRotationYaw.Quaternion(), UKismetMathLibrary::MakeVector(X, Y, Z));
}
上一篇
下一篇