UE5 与 C++ 入门教程·第一课:角色与 Enhanced Input

本文主要围绕 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 Map 和 Game Default Map 设置为新建的关卡。

角色资产

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

Belica 资产

创建一个 Character 类

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

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

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

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

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

Spring Arm 与 Camera

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

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

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

在 BelicaCharacter.h 中添加两个组件:

private:
	UPROPERTY(VisibleAnywhere, Category = Camera)
		class USpringArmComponent* CameraBoom;

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

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

class USpringArmComponent;
class UCameraComponent;

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

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

所在头文件

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

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

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 有自己的一套内存管理机制,不能简单的进行 new 和 delete。需要使用 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 Type 是 Digital(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,将 X 和 Z 的勾选去掉。

Input Mapping Context

Enhanced Input

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

默认的 Input Component

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

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

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

生成 Visual Studio 文件

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

角色中加入 IA 和 IMC

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

	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。

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

在 .cpp 引入头文件:

#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 操作,只需要实现 Move 和 Look。

处理值的方法

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 提供了动画蓝图(突然手动制作的欲望就下降了)。