本文主要围绕 UE5 新的输入系统,手把手从 0 搭建 Unreal 项目,实现角色的基础移动。

重要提示:众所周知,C++ 属于编译型语言,因此动态灵活性不足,不过执行效率高,而蓝图简单灵活,却执行效率低。因此推荐一种开发方式—— 用 C++ 创建基类,蓝图继承 C++ 的基类 ,获得一种折衷的优势。

  • 开发工具:Visual Studio 2022
  • Unreal 版本:5.2.1

创建项目

  1. 新建一个基于 C++ 的空项目。
  2. File -> New Level(或者 Ctrl + N),建立一个 Basic 关卡,保存到新建的 Levels 文件夹中;
  3. Edit -> Project Settings… -> Project -> Maps & Modes 里将 Editor Startup MapGame Default Map 设置为新建的关卡。

角色资产

这里选用的 Epic 商城里的免费资产,Paragon: Lt. Belica。目前用的版本是(写的时候的最新的版本) 5.2.1,目前 Belica 还不支持这个版本,选一个最近的版本 5.1 添加到项目中。

Belica 资产

创建一个 Character 类

正如开头所说,我们希望获得一种相对优势,因此会用 C++ 作为父类,项目中实际使用的时候用创建蓝图类继承自 C++。对于简单的参数和功能就可以在蓝图上定制,对于通用或核心的功能或方法则可以在上级 C++ 类中实现。

Content BrowserC++ Classes 点击右键 New C++ Class... 或者在顶部 Tools -> New C++ Class…

这里并没有放到默认目录,而是放到 Character 目录里。注意,这里生成的 C++ 代码会有一个头文件的问题。

1
2
3
4
5
// 由于将类创建到 `Character` 目录下,自动生成的代码是下面的这样
#include "Character/BelicaCharacter.h"

// 去掉 Character/,改成下面这样,否则编译会出错
#include "BelicaCharacter.h"

Spring Arm 与 Camera

角色后面跟着一个相机,叫做 跟随相机(followed camera)。为了控制相机与角色的相对距离,通常还带一个弹簧臂(Spring Arm)。调整弹簧臂就可以调节相机的远近。可以说这是所有角色型游戏的基础。

因此,相机是依附于弹簧臂的。 弹簧臂动,相机就动。

我们需要两个组件,USpringArmComponentUCameraComponent。前缀 U 代表继承自 UObject

BelicaCharacter.h 中添加两个组件:

1
2
3
4
5
6
private:
UPROPERTY(VisibleAnywhere, Category = Camera)
class USpringArmComponent* CameraBoom;

UPROPERTY(VisibleAnywhere, Category = Camera)
class UCameraComponent* FollowCamera;

这里使用了前向声明的小技巧,对于指针类型,可以声明类型,而不需要引入头文件,这样可以避免影响编译速度。这就是为什么使用 class。当然,如果你愿意的话,也可以单独放到文件的开头进行声明,类似下面这样:

1
2
class USpringArmComponent;
class UCameraComponent;

UPROPERTY 是 Unreal 定义的宏。某种意义上也是黑魔法,例如你可以让 private 也可以被访问。UPROPERTY 用途非常广,比如变量复制、序列化、蓝图访问、垃圾回收。本文作为入门教程就不展开了。

BelicaCharacter.h 中使用了前向声明,但在 BelicaCharacter.cpp 中,我们就需要关注类的具体方法,因此就必须引入头文件。要查询类在哪个头文件里,最简单粗暴但有效的方法就是把类名贴入搜索引擎。如果你使用的是 Jetbrains 家的 Rider 进行 Unreal 开发,IDE 会自动帮你补全头文件,就可以不用操心这个问题。(特此声明,本人没有收取任何 Jetbrains 广告费)

所在头文件

1
2
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"

在构造函数中初始化这两个组件,并指明层级关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
ABelicaCharacter::ABelicaCharacter()
{
PrimaryActorTick.bCanEverTick = true;

CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
CameraBoom->SetupAttachment(GetMesh());
CameraBoom->TargetArmLength = 500.f;
CameraBoom->bUsePawnControlRotation = true;

FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;
}
  • Unreal 有自己的一套内存管理机制,不能简单的进行 newdelete。需要使用 CreateDefaultSubobject 这样的模板函数,背后也有比较复杂的分配机制。
  • SetupAttachment 就是用来绑定层级的。可以看到 FollowCamera 绑定到了 CameraBoom 上。而 CameraBoom 绑定的是 GetMesh 返回的角色的骨骼。=
  • TargetArmLength 是弹簧臂的长度
  • bUsePawnControlRotation 是使用 Pawn 控制旋转

到了这里,可以进行编译了。

创建蓝图类

可以基于前面的 C++ 类创建一个蓝图类。

从 C++ 中创建蓝图类

可以看到 C++ 代码的层级关系体现在这里,弹簧臂依附与骨骼(Mesh),跟随相机又依附于弹簧臂。

层级关系

Mesh 设置为我们引入的资产,调整位置和旋转。顺便可以调节一下弹簧臂的高度和长度(不用调跟随相机)。

设置 Belica 网格体

可以将蓝图类拖入到关卡中查看效果。

动画蓝图

Belica 资产里已经做好了一个动画蓝图,可以直接使用。至于什么是动画蓝图,简单理解就是一系列动画组合而成,能根据某种状态(例如用户输入)自动切换和过渡动画的动画控制逻辑。后面如果有动画的文章会展开讲制作动画蓝图(也许鸽了)。

在蓝图添加 Belica 的动画蓝图:

添加动画蓝图

运行一下,会有激动人心的动画效果出现,应该可以看到 Belica 拿起枪自信又警觉的样子。

Input Action

这里会使用 Unreal 新版本的 Input 功能,相比于 UE4,看起来稍微复杂一点点,但优势也比较明显,就是可定制性更高,层次更加分明。Input Action 可以理解定义了值类型,对于代码,主要关注 Action 的值,不关心怎么出现这个值的。至于 Action 怎么产生,由谁产生(键盘、手柄、触控等等),则是 Input Mapping Context 要定义的,也包括值的处理过程。

创建一个 Inputs 文件夹,然后再创建一个 Action 文件夹,创建 Input Action(点进去修改类型)。

  • IA_Jump:处理跳跃,对应 Value TypeDigital(bool)(一次性触发);
  • IA_Move:移动,由于这是个有正负方向,而且上下左右,使用 Axis2D(Vector2D)
  • IA_Look:鼠标移动,也是 Axis2D(Vector2D)

创建 Input Action

Input Action 可以视为 Action 名称(标签)和值类型的映射。

Input Mapping Context

在 Inputs 文件夹中创建 IMC_Belica。注意 Negate 对值进行取反(负)方向。空格和 D 不需要特别配置。Look 的 Nagate 只作用于 Y,将 XZ 的勾选去掉。

Input Mapping Context

Enhanced Input

如果使用 5.x 版本以上,Enhanced Input 已经作为默认的 Input 组件(毕竟已经在编辑器里警告说废弃原有 Input 了),但新的 Input 和原有的 Input 是兼容的。如果你看代码的话,新的 EnhancedInputComponent 其实就是 InputComponent 的子类。不放心的话可以去看一眼。

默认的 Input Component

.Build.cs 中将 EnhancedInput 添加作为依赖(因为这里是空项目起步,不是第三人称模板,所以需要手动添加)。

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

然后刷新 Visual Studio 项目。可以在编辑器顶部的 Tools -> Refresh Visual Studio 2022 Project。或者在文件夹下,对着 .uproject 右键,生成 Visual Studio project files。

生成 Visual Studio 文件

这一步的目的是让依赖的头文件加入到 Visual Studio,否则编译器会找不到头文件。不过,是可以正常编译的。如果你能忍受红色的波浪线,其实也可以不刷新。

角色中加入 IA 和 IMC

有了弹簧臂和跟随相机的铺垫,这一步其实没有太多要讲的。

1
2
3
4
5
6
7
8
9
10
11
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
class UInputMappingContext* BelicaMappingContext;

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

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

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

但这有个黑魔法,meta = (AllowPrivateAccess = "true") 让蓝图可以访问到私有变量。

初始化和绑定操作

最终的 Action 需要有一个对应的方法去处理这个操作。注意一下,UE4 的 Input 是区分 Axis 和 Action,但现在都已经统一成为 Action 了。所以,都是 BindAction

还有一个小点,BelicaMappingContextAction 之间其实并 没有严格的初始化顺序的关系。 本质上,这是两层的抽象,Context 主要是外部输入到 Action。Action 则负责最后绑定到操作上。

在 .cpp 引入头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "EnhancedInputSubsystems.h"
#include "EnhancedInputComponent.h"

// ...

// Called to bind functionality to input
void ABelicaCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);

if (APlayerController* PlayerController = Cast<APlayerController>(Controller))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
{
Subsystem->AddMappingContext(BelicaMappingContext, 0);
}
}

if (UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(PlayerInputComponent))
{

EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Triggered, this, &ACharacter::Jump);
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping);

EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ABelicaCharacter::Move);
EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &ABelicaCharacter::Look);

}

}

跳跃的处理方法是用 ACharacter 自带的处理方法。所以处理 Action 操作,只需要实现 MoveLook

处理值的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void ABelicaCharacter::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);

AddMovementInput(ForwardDirection, MovementVector.Y);
AddMovementInput(RightDirection, MovementVector.X);
}
}

void ABelicaCharacter::Look(const FInputActionValue& Value)
{
FVector2D LookVector = Value.Get<FVector2D>();

if (Controller != nullptr)
{
AddControllerYawInput(LookVector.X);
AddControllerPitchInput(LookVector.Y);
}
}

需要注意,在头文件中声明时,FInputActionValue 是类型的引用,不能使用前向声明。所以需要在 头文件 中(也就是 BelicaCharacter.h)加入 #include "InputActionValue.h"

GameMode 和 接收输入

将 Action 和 IMC 在角色进行设置,同时接收 Player 0 的输入。

将 Action 和 IMC 设置到角色上

接下去就比较简单了,创建 C++ 项目会自动创建一个 GameMode,直接继承一个蓝图的 GameMode 类。

GameMode类

将 Pawn 设置为 Belica 的蓝图类,可以删除场景内的 Belica。

设置 Belica 作为 Pawn

在 World Settings 中设置这个 GameMode。

设置 GameMode

运行游戏,现在 Belica 可以受键盘和鼠标控制移动了。

对了,如果你觉得不希望只能看到 Belica 的背影,可以在 C++ 的构造函数中设置 bUseControllerRotationYaw = false;,当然更简单的方法是在蓝图直接勾选去掉(这才是蓝图的意义,额,其实本文都可以用蓝图)。

去掉ControllerRotationYaw

小结

从 0 开始,最终实现 UE5 新版的 EnhancedInput。这是一切的开始。不过,令我意外的是 Belica 提供了动画蓝图(突然手动制作的欲望就下降了)。