开篇
"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控制旋转
- 然后点这里
右侧属性面板,取消使用控制器旋转,勾选将旋转朝向运动。
在PlayerController中设置运动和视角变化的输入。这里网络的判断是DS模式下需要的,正常单机模式不需要
然后是移动输入按键触发输入事件
视角输入
然后处理动画状态机
首先有一个空的动画蓝图,我觉得第一步应该是弄出这个两个状态,Moving放一个奔跑动画,Not Moving放一个IDLE动画。切换条件是是否有加速度,然后同步到动画蓝图里面
然后直接输出
如果是角色朝向速度方向的那种旋转方式,也就是勾选将旋转朝向运动。并且我希望移动到静止慢一点,这里混合设置调为0.5,默认是0.2。
效果还不错,如果这个是一个小兵,或者是不怎么关注角色运动的手游,或者你建项目是为了测试其他东西 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。
Input目录下Input Context Mapping
然后在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);
}
}
基于状态机的摄像机系统
基础摄像机
比较多的资料都是这么干的。在角色蓝图里面添加一个弹簧臂和摄像机。然后改改参数,够用了。
ALSv4中不这么干。另一种方式是
可以选择在PlayerController 上关联 PlayerCameraManager 这个类。
然后实现虚函数
/*
* 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 就是常见的游戏中的瞄准状态。
我们可以获取角色的身上的属性。然后摄像机的状态机也一样会切换。我们只需要在不同的状态机中修改曲线值。
这样摄像机的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));
}