언리얼에선 플레이어의 모든 조작의 판단은 플레이어 컨트롤러 클래스에서 따로 처리해야 한다.
컨트롤러에 의한 판단은 캐릭터 클래스에서 따로 처리한다.
컨트롤러는 입력을 캐릭터 클래스에 전달해주기만 하면 된다.
플레이어 컨트롤러의 주요 기능
1. 입력 처리 - Enhanced Input System -> 언리얼 엔진5에 도입된 기능, 더 구조적으로 입력처리가 가능하게 됨
2. 카메라 제어 로직 담당 - 카메라가 시점을 돌리거나 줌인아웃을 어떻게 하는지에 대한 로직을 관리함
3. UI와의 상호작용 - 사용자가 버튼을 누르거나 드래그 드랍 이벤트 발생시에 상호작용을 관리
4. Possess/Unpossess - 빙의 기능(캐릭터 / 폰을 움직일 수 있게 해줌)관리

C++ 플레이어 컨트롤러 클래스를 생성한다.

생성한 C++클래스를 상속해서 블루프린트 클래스를 생성한다.
SpartaGameMode.cpp
#include "SpartaGameMode.h"
#include "SpartaCharacter.h"
#include "SpartaPlayerController.h"
ASpartaGameMode::ASpartaGameMode()
{
DefaultPawnClass = ASpartaCharacter::StaticClass();
PlayerControllerClass = ASpartaPlayerController::StaticClass();
}
C++코드로 돌아가서 게임 모드 cpp파일의 생성자 함수에 플레이어 컨트롤러 클래스를 지정해준다.

코드를 빌드해주고 언리얼 에디터로 돌아가서 블루프린트 게임모드 클래스로 돌아가보면 C++ 게임 모드 클래스로 바뀌어 있을텐데 이걸 블루프린트 게임모드 클래스로 변경해준다.

이제 프로그램을 실행시켜보면 우측 아웃라이너창에서 블루프린트 플레이어 컨트롤러 클래스가 활성화되어 있는 것을 볼 수 있다.
Input Action(IA) : 특정 동작을 추상화(점프 -> IA_Jump, 마우스 회전 -> IA_Look, 이동 -> IA_Move)
Input Mapping System(IMC) : IA들을 총괄해서 관리
구현해줄 동작들
1. 이동(WASD)
2. 마우스를 이용한 회전
3. 점프
4. 스프린트(L_SHIFT)

콘텐츠 브라우저에서 우클릭하여 Input Action을 생성해준다.

IA의 Value Type을 보면 다양한 Value Type가 존재한다.
Value Type : 게임 상에서 어떤 유형의 값을 변형시킬 것인지 정함
- Digital(bool) : On, Off를 관리, 키를 누르면 On, 떼면 Off(점프, 공격, 스프린트)
- Axis1D(float) : 단일 축, 전진 후진만 관리
- Axis2D(Vector2D) : x, y 두 축을 동시 처리
- Axis3D(Vector) : 3차원 축을 동시에 처리, 비행시뮬레이션 같은 경우에 사용
Modifiers : 값을 다양한 방식으로 변환시켜줌
- DeadZone : 입력의 민감도를 조절, 미세한 입력은 무시(아날로그 조이스틱의 경우)
- Negate : 입력값을 반전시킴(상하반전, 좌우반전을 이용할 때 사용)
- Scalar : 입력값의 크기 배율을 크게 만들어줌(민감도를 올릴 때 사용)
- Swizzle Input Axis Values : 입력값의 축을 재구성 해줌
IA_Move의 경우엔 앞뒤좌우로 움직이기 때문에 Axis2D(Vector2D)로 설정해준다.
모디파이어 설정은 해주지 않아도 된다.



점프와 스프린트는 이동이 아닌 키를 눌렀는지만 확인하면 되기 때문에 트리거를 Digital(bool)로 설정해주고 모디파이어는 설정해주지 않아도 된다.


IA_Look은 마우스의 움직임에 따라 시점의 회전을 담당한다. 2D의 움직임이 필요하므로 트리거를 Axis2D(Vector2D)로 설정한다.


이제 Input Mapping Context를 생성해준다.

IMC에서 이제 IA 별로 어떤 키를 입력 받고 입력을 어떤식으로 인식할지 정해야 한다.
각 키 별로 매핑을 해준 다음에 트리거와 모디파이어를 설정해주어야 한다.
IA_Move에선 W에서 Swizzle Input Axis Values로 설정해주고 축의 순서를 X가 가장 앞으로 오게 해야 한다. W키를 눌렀을 때 앞으로 가야 하기 때문에 W키를 눌렀을 때 X축의 값이 +가 되도록 해야한다.

S키에서도 똑같이 Swizzle Input Axis Values와 축을 설정을 해주고 모디파이어에서 Negate를 설정해준다. S키는 뒤로 가야 하기 때문에 X축의 값이 -가 돼야 하기 때문이다.

A키에선 왼쪽으로 이동해야 하기 때문에 y축의 값이 -가 돼야 한다.
Swizzle Input Axis Values을 고르고 Y축이 가장 앞으로 오게 설정해주고 Negate를 설정해준다.
D키는 A키와 동일하게 하되 Negate만 설정해주지 않으면 된다.

점프키는 키보드 입력만 받으면 되기 때문에 키 지정만 해준다.

마우스를 이용한 시점 변환은 마우스의 움직임을 2D로 전달 받아야 한다.
마우스 항목의 Mouse XY 2D-Axis로 키 지정을 해준다.

그리고 Y축 이동에 대해서 Negate로 설정해서 Y축 이동에 대해 반전이 필요하다.
기본적으로 마우스 Y축의 움직임은 위로 움직이는게 양수, 아래로 움직이는게 음수이다.
그런데 카메라 같은 경우는 상하 회전을 하기 때문에 위로 가면 음수, 아래로 가면 양수가 된다.
그래서 Y축 반전을 해주지 않으면 마우스의 Y축 움직임이 반대로 가게 되기 때문에 헷갈리게 된다.

Sprint도 키의 입력만 받으면 되기 때문에 키 지정만 해준다.
이제 C++ 컨트롤러 클래스로 돌아가서 코드를 수정해서 컨트롤러 클래스가 IMC의 영향아래에 있도록 만들어야 한다.
// SpartaPlayerController.h
class UInputMappingContext; // IMC 미리 선언
class UInputAction; // IA 미리 선언
UCLASS()
class SPARTAPROJECT_API ASpartaPlayerController : public APlayerController
{
GENERATED_BODY()
public:
ASpartaPlayerController();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputMappingContext* InputMappingContext;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* MoveAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* JumpAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* LookAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* SprintAction;
}
이렇게 IA와 IMC에 해당하는 멤버 변수들을 선언해주고 리플렉션 시스템에 등록해준다.
// SpartaPlayerController.cpp
#include "SpartaPlayerController.h"
ASpartaPlayerController::ASpartaPlayerController()
: InputMappingContext(nullptr),
MoveAction(nullptr),
JumpAction(nullptr),
LookAction(nullptr),
SprintAction(nullptr)
{
}
이제 cpp파일로 가서 생성자 함수에 우리가 선언한 IA, IMC 멤버변수들에게 값을 연결시켜주어야 하는데 IA, IMC는 블루프린트 상에서 키를 할당해줄 것이기 때문에 전부 nullptr로 지정을 해준다.
아예 아무것도 지정해주지 않는 것 보다는 nullptr로 지정해주는 것이 안전하기 때문이다.
// SpartaPlayerController.h
class UInputMappingContext; // IMC 미리 선언
class UInputAction; // IA 미리 선언
UCLASS()
class SPARTAPROJECT_API ASpartaPlayerController : public APlayerController
{
GENERATED_BODY()
public:
...
protected:
virtual void BeginPlay() override;
}
이제 플레이어 컨트롤러가 생성이 되고난 직후에 바로 IMC를 활성화 해줄 것이기 때문에 BeginPlay함수를 오버라이드 해준다.
// SpartaPlayerController.cpp
#include "EnhancedInputSubsystems.h"
void ASpartaPlayerController::BeginPlay()
{
Super::BeginPlay();
if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (InputMappingContext)
{
Subsystem->AddMappingContext(InputMappingContext, 0);
}
}
}
}
이제 위와 같은 함수를 선언해서 IMC를 활성화해주어야 한다.
우선 GetLocalPlayer라는 걸 가져와야 한다.
LocalPlayer란 현재 사용자가 컨트롤하는 플레이어의 로컬 플레이어 객체를 의미한다.
이는 플레이어의 입력이나 화면 뷰를 관리하는 객체이다.
그 다음 LocalPlayer의 UEnhancedInputLocalPlayerSubsystem을 가져온다.
UEnhancedInputLocalPlayerSubsystem은 입력 시스템을 관리한다. 이게 IMC를 추가하고 삭제하는 역할을 한다.
그런 다음 헤더파일에서 선언 InputMappingContext가 생성이 되었는지 확인하고 InputMappingContext가 존재한다면 AddMappingContext함수로 InputMappingContext를 활성화시킨다. AddMappingContext함수의 인자 0은 우선순위를 뜻하며 가장 높은 우선순위로 두라는 의미를 가진다.
다른 IMC들이랑 겹치는 특수한 상황이 있을 때 우선순위를 체크하여 우선순위가 높은 걸로 할당하게 된다.
간단히 요약하자면 로컬 플레이어의 입력체계에 우리가 설정한 InputMappingContext를 넣어준것이다.

코드를 빌드하고 언리얼 에디터에서 컨트롤러 클래스로 가보면 코드에서 리플렉션 시스템에 등록된 변수들이 전부 업데이트 되어 있는 것을 볼 수 있다.
nullptr로 설정해주었기 때문에 전부 지정이 되어 있지 않다.

이름에 맞게 우리가 생성한 IA들로 전부 설정해준다.

그런 다음 블루프린트 플레이어 컨트롤러 클래스로 가보면 우리가 만든 IA들이 이벤트 노드로써 생성할 수 있는 걸 확인할 수 있다.

IA노드들을 전부 만든 다음 Print String노드와 붙여서 제대로 작동하는지 확인한다.

키를 다 눌러보고 제대로 작동하는지 확인해주면 된다.
이제 캐릭터 컨트롤러를 통해 키 입력이 가능해졌으니 이것을 기반으로 캐릭터를 실제로 움직여보자.
// SpartaPlayerController.cpp
#include "EnhancedInputSubsystems.h"
void ASpartaPlayerController::BeginPlay()
{
Super::BeginPlay();
if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (InputMappingContext)
{
Subsystem->AddMappingContext(InputMappingContext, 0);
}
}
}
}
아까 구현한 코드를 다시 보자면, 사용자가 조작을 하면 컨트롤러가 그 조작을 받아온 다음 Local Player SubSystem에 이게 어떤 동작인지 물어본다.
Local Player SubSystem이 자신한테 등록된 IMC를 바탕으로 답을 해주면 컨트롤러는 그 답을 받아서 Character에 동작을 지시하게 된다.
이제 Charcter클래스에서 컨트롤러 클래스를 가져온 다음 입력된 키를 확인하고 실제 움직임을 구현해보자.
protected:
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
Character클래스의 헤더파일을 보면 SetupPlayerInputComponent함수가 있을 것이다.
이 함수는 우리가 만든 IA와 Character에서 구현할 실제 동작을 지시하는 함수들을 연결시켜주는 역할을 한다.
protected:
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
UFUNCTION()
void Move(const FInputActionValue& value);
UFUNCTION()
void StartJump(const FInputActionValue& value);
UFUNCTION()
void StopJump(const FInputActionValue& value);
UFUNCTION()
void Look(const FInputActionValue& value);
UFUNCTION()
void StartSprint(const FInputActionValue& value);
UFUNCTION()
void StopSprint(const FInputActionValue& value);
일단 헤더파일에서 움직임을 지시해줄 함수들을 전부 선언해준 다음 UFUNCTION()을 이용해 리플렉션 시스템에 등록해준다.
이전에 UFUNCTION에 인자를 아무것도 두지 않으면 언리얼에서 함수의 존재만을 파악하고 그 외에 것은 할 수 없다고 했었는데, 이 함수들은 블루프린트 내에서 수정할 내용들이 아니기 때문에 언리얼에서 이 함수를 인식하기만 하면된다.
그렇다고 이 함수들을 리플렉션 시스템에 등록하지 않으면 함수를 제대로 구현하더라도 언리얼 내에서 캐릭터가 움직이질 않으니 리플렉션 시스템에 등록은 해주어야 한다.
#include "SpartaPlayerController.h"
#include "EnhancedInputComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
캐릭터의 움직임을 위해 3개의 헤더파일을 참조해주어야한다.
SpartaPlayerController헤더파일은 사용자의 입력을 받아오기 위해 필요하다.
EnhancedInputComponent은 우리가 사용하고 있는 입력을 처리하는 컴포넌트이다. 사용자의 입력을 받아와서 인식하는데에 필요하다.
GameFramework/CharacterMovementComponent는 언리얼에서 캐릭터 클래스에 기본으로 생성해주는 컴포넌트로 캐릭터의 이동에 관련된 기본적인 기능들을 지원해주는 컴포넌트다.
void ASpartaCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
//바인딩
if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
}
}
본격적으로 움직임을 구현하기 전에 바인딩을 통해 어떤 IA가 일어났을 때 어떤 함수를 작동시킬지 연결시켜주어야 한다.
SetupPlayerInputComponent함수의 매개 변수인 UInputComponent는 기본적으로 입력을 처리하는 컴포넌트로 다양한 방식을 지원하기 때문에 그 다양한 방식들 중에서도 우리가 사용하고 있는 UEnhancedInputComponent로 캐스팅을 해주어야 한다.
void ASpartaCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
//바인딩
if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
if (ASpartaPlayerController* PlayerController = Cast<ASpartaPlayerController>(GetController()))
{
}
}
}
그 다음 현재 캐릭터를 조작하는 컨트롤러를 GetController함수를 통해 가져오고 그걸 ASpartaPlayerController로 캐스팅하여 PlayerController에 넣어준다.
실제 사용자의 조작 내역을 캐릭터 클래스로 불러오는 것이다.
이제 이 PlayerController로 어떤 IA 이벤트가 일어날 때 어떤 함수를 이어줄 것인지 정해주어야 한다.
void ASpartaCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
//바인딩
if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
if (ASpartaPlayerController* PlayerController = Cast<ASpartaPlayerController>(GetController()))
{
if (PlayerController->MoveAction) /
{
EnhancedInput->BindAction(
PlayerController->MoveAction,
ETriggerEvent::Triggered,
this,
&ASpartaCharacter::Move
);
}
}
}
}
먼저 if문으로 MoveAction이 Null인지 체크해준 다음 BindAction으로 이벤트랑 함수를 연결해주어야한다.
BindAction으로의 첫 번째 인자엔 우리가 만든 IA_Move를 가져온다.
두 번째 인자엔 이벤트가 발생하는 상황을 설정한다. Move의 경우엔 키가 눌렸을 경우 실행할 것이기 때문에 ETriggerEvent::Triggered로 설정해준다.
세 번째 인자엔 this를 입력하여 입력 이벤트가 발생했을 때 호출되는 함수의 객체(Character)를 입력해준다.
네 번째 인자엔 실제로 호출된 함수를 가져와서 IA_Move이벤트가 발생했을 때 해당 함수를 실행시키겠다는 의미를 가진다.
이제 모든 동작에 대해 이 과정을 해주어야 한다.
void ASpartaCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
//바인딩
if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
if (ASpartaPlayerController* PlayerController = Cast<ASpartaPlayerController>(GetController()))
{
if (PlayerController->MoveAction) /
{
EnhancedInput->BindAction(
PlayerController->MoveAction,
ETriggerEvent::Triggered,
this,
&ASpartaCharacter::Move
);
if (PlayerController->JumpAction)
{
EnhancedInput->BindAction(
PlayerController->JumpAction,
ETriggerEvent::Triggered,
this,
&ASpartaCharacter::StartJump
);
EnhancedInput->BindAction(
PlayerController->JumpAction,
ETriggerEvent::Completed,
this,
&ASpartaCharacter::StopJump
);
}
if (PlayerController->LookAction)
{
EnhancedInput->BindAction(
PlayerController->LookAction,
ETriggerEvent::Triggered,
this,
&ASpartaCharacter::Look
);
}
if (PlayerController->SprintAction)
{
EnhancedInput->BindAction(
PlayerController->SprintAction,
ETriggerEvent::Triggered,
this,
&ASpartaCharacter::StartSprint
);
EnhancedInput->BindAction(
PlayerController->SprintAction,
ETriggerEvent::Completed,
this,
&ASpartaCharacter::StopSprint
);
}
}
}
}
이제 실제 캐릭터를 움직여줄 함수들만 구현하면 된다.
void ASpartaCharacter::Move(const FInputActionValue& value)
{
if (!Controller) return;
}
먼저 현재 컨트롤러가 존재하는지를 체크해야 한다. 이걸 체크해야 하는 이유는 이후에 캐릭터를 움직이는 함수인 AddMovementInput함수가 컨트롤러가 없으면 작동하지 않는 함수이기 때문에 컨트롤러가 존재하는지 먼저 체크해주어야 한다.
void ASpartaCharacter::Move(const FInputActionValue& value)
{
if (!Controller) return;
const FVector2D MoveInput = value.Get<FVector2D>();
}
그 다음 입력된 value값을 FVector2D 구조체 형태로 변환하여 변수에 저장한다.
이제 캐릭터를 움직이게 해주면 된다.
void ASpartaCharacter::Move(const FInputActionValue& value)
{
if (!Controller) return;
const FVector2D MoveInput = value.Get<FVector2D>();
if (!FMath::IsNearlyZero(MoveInput.X))
{
AddMovementInput(GetActorForwardVector(), MoveInput.X);
}
if (!FMath::IsNearlyZero(MoveInput.Y))
{
AddMovementInput(GetActorRightVector(), MoveInput.Y);
}
}
AddMovementInput함수는 현재 캐릭터의 위치를 MoveInput값만큼 증가시켜준다. GetActorForwardVector는 현재 캐릭터의 x좌표 위치를 반환한다.
현재 캐릭터의 x좌표에 입력된 2D좌표에서 x값만을 더하여 캐릭터의 위치를 변환시킨다.
GetActorRightVector는 캐릭터의 위치중 y축 값을 반환한다.

이제 코드를 빌드하고 프로그램을 실행시켜 캐릭터를 움직여보자.
void ASpartaCharacter::StartJump(const FInputActionValue& value)
{
if (value.Get<bool>())
{
Jump();
}
}
void ASpartaCharacter::StopJump(const FInputActionValue& value)
{
if (!value.Get<bool>())
{
StopJumping();
}
}
점프는 간단하다. 점프는 Value Type이 bool이기 때문에 bool로 value를 캐스팅하여 가져오고 그 값이 true인 경우 점프를 실행시키면 된다.
Jump와 StopJumping는 캐릭터 무브먼트 컴포넌트에 존재하는 함수들로 각각 캐릭터를 점프시키는 함수와 캐릭터의 점프를 멈추게 하는 함수이다.
점프 함수에는 컨트롤러의 존재를 체크하는 if문이 존재하지 않는데 이유는 캐릭터 무브먼트 컴포넌트에서 지원하는 함수들은 기본적으로 컨트롤러의 존재를 체크하는 코드가 포함되어 있기 때문이다.

void ASpartaCharacter::Look(const FInputActionValue& value)
{
FVector2D LookInput = value.Get<FVector2D>();
AddControllerYawInput(LookInput.X);
AddControllerPitchInput(LookInput.Y);
}
Look함수도 Move함수와 동일하게 마우스의 움직임을 FVector2D 구조체로 변형시켜 가져온 다음 컨트롤러의 Yaw와 Pitch를 바꿔주면 된다.

캐릭터를 달리게 만들기 전에 캐릭터의 속도를 지정해줄 변수를 헤더파일에서 멤버변수로 선언하자
UCLASS()
class SPARTAPROJECT_API ASpartaCharacter : public ACharacter
{
GENERATED_BODY()
...
private:
float NormalSpeed;
float SprintSpeedMultiplier;
float SprintSpeed; // 블루 프린트로 노출시키진 않음
};
평소 걸을 때의 속도인 NormalSpeed와 달릴 때의 속도인 SprintSpeed, 그리고 SprintSpeed는 NormalSpeed의 배속을 해주어서 정하게 되는데 그 배속을 지정하는 SprintSpeedMultiplier을 선언해주자.
ASpartaCharacter::ASpartaCharacter()
{
...
NormalSpeed = 600.0f;
SprintSpeedMultiplier = 1.7f;
SprintSpeed = NormalSpeed * SprintSpeedMultiplier;
GetCharacterMovement()->MaxWalkSpeed = NormalSpeed;
}
생성자에서 멤버 변수들을 초기화시켜주자.
GetCharacterMovement()->MaxWalkSpeed를 바꾸면 캐릭터의 이동속도가 가속하는 과정 없이 즉시 바뀌게 된다.
void ASpartaCharacter::StartSprint(const FInputActionValue& value)
{
if (GetCharacterMovement())
{
GetCharacterMovement()->MaxWalkSpeed = SprintSpeed;
}
}
void ASpartaCharacter::StopSprint(const FInputActionValue& value)
{
if (GetCharacterMovement())
{
GetCharacterMovement()->MaxWalkSpeed = NormalSpeed;
}
}
이제 달리는 함수들을 구현해주면 된다. GetCharacterMovement를 성공적으로 가져오면 캐릭터의 스피드를 걷는 속도 혹은 달리는 속도로 고정시켜주게 된다.

블루프린트로도 해본 작업이지만 너무 귀찮은 작업같다. 하나하나 지정해줄 것도 많고 동작이 많아지면 그만큼 해줘야 할 것이 늘어난다.
하지만 실제로 캐릭터가 움직이는 것을 봐가면서 구현을 하니까 C++로 콘솔창 봐가면서 코딩하는 것보다 훨씬 재밌었다.