오늘은 지난번 Pawn 클래스 만들기에 이어 도전 과제를 수행했다. 먼저 캐릭터를 6자유도(6-DOF)로 움직이는 드론처럼 만들고, Q/E 키를 눌렀을 때 기체가 자연스럽게 기울어지는 효과까지 구현했다. 여기서 한 단계 더 나아가, 언리얼의 물리 엔진을 사용하지 않고 LineTrace와 Tick 함수를 이용해 직접 "인공 중력" 시스템을 만들었다. 중력 가속도를 계산해 낙하하고, 바닥을 감지해 착지하며, 공중에 있을 땐 이동 속도를 줄이는 "에어 컨트롤"까지 구현하며 게임 캐릭터 이동의 깊이를 제대로 맛볼 수 있었다. 🛸
FMath::FInterpTo를 사용해 키 입력에 따라 기체가 부드럽게 기울어지는 "롤(Roll)" 효과를 만든다.Tick 함수에서 직접 속도를 계산하는 인공 중력을 구현한다.LineTrace를 사용해 바닥을 감지하는 GroundCheck 함수를 작성한다.6자유도 (6 Degrees of Freedom, 6-DOF)
보간 (Interpolation) - FMath::FInterpTo
A라는 값에서 B라는 값으로 부드럽게 변화시키고 싶을 때 사용하는 수학 기법이다.FInterpTo(현재값, 목표값, DeltaTime, 보간속도) 형태로 사용한다.CurrentRollDeg)를 목표 기울기(TargetRollDeg)까지 부드럽게 변화시키는 데 사용했다. 이걸 쓰지 않으면 드론이 딱딱하게 꺾여서 보기에 좋지 않다.라인 트레이스 (Line Trace)
GroundCheck에 아주 유용하다.FHitResult)를 얻을 수 있는데, 특히 ImpactNormal(충돌면의 법선 벡터)을 확인하면 경사면인지 평지인지 구분할 수 있다. (Z값이 1에 가까울수록 평평함)인공 중력 로직
속도 = 이전 속도 + (가속도 * 시간)을 코드로 옮긴 것이다.Tick 함수에서 매 프레임마다 VerticalVelocity += GravityAccel * DeltaTime; 코드를 실행하여 중력의 영향을 누적시킨다.VerticalVelocity만큼 캐릭터를 아래로 이동시키면 자연스러운 낙하가 구현된다.#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "MyPawn.generated.h"
class UCapsuleComponent;
class USkeletalMeshComponent;
class USpringArmComponent;
class UCameraComponent;
UCLASS()
class SPARTA6_7_API AMyPawn : public APawn
{
GENERATED_BODY()
public:
AMyPawn();
protected:
// 게임 시작 시 호출
virtual void BeginPlay() override;
// 매 프레임 호출: 롤링, 중력 처리
virtual void Tick(float DeltaTime) override;
// 입력 컴포넌트 설정
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
// 입력 처리 함수
UFUNCTION() void Move(const FInputActionValue& value); // 이동 (전/후/좌/우)
UFUNCTION() void Look(const FInputActionValue& value); // 시점 회전
UFUNCTION() void Elevate(const FInputActionValue& Value); // 이동 (상/하)
UFUNCTION() void Roll(const FInputActionValue& Value); // 기울기 (좌/우)
private:
// 컴포넌트
UPROPERTY(VisibleAnywhere) UCapsuleComponent* Capsule;
UPROPERTY(VisibleAnywhere) USkeletalMeshComponent* Mesh;
UPROPERTY(VisibleAnywhere) USpringArmComponent* SpringArm;
UPROPERTY(VisibleAnywhere) UCameraComponent* Camera;
// 이동/회전 변수
UPROPERTY(EditAnywhere, Category = "Movement") float MoveSpeed = 600.0f;
UPROPERTY(EditAnywhere, Category = "Movement") float LookYawSpeed = 0.1f;
UPROPERTY(EditAnywhere, Category = "Movement") float LookPitchSpeed = 0.1f;
UPROPERTY(EditAnywhere, Category = "Movement") float MaxRollDeg = 30.0f; // 최대 롤 각도
UPROPERTY(EditAnywhere, Category = "Movement") float RollReturnSpeed = 8.0f; // 롤 복귀 속도
float TargetRollDeg = 0.0f; // 목표 롤 각도
float CurrentRollDeg = 0.0f; // 현재 롤 각도
FRotator MeshBaseRelativeRot; // 메시 초기 회전값 (롤링 기준점)
// 인공 중력 변수
UPROPERTY(EditAnywhere, Category = "Gravity") float GravityAccel = -980.f; // 중력 가속도
UPROPERTY(EditAnywhere, Category = "Gravity") float MaxFallSpeed = 4000.f; // 최대 낙하 속도
UPROPERTY(EditAnywhere, Category = "Gravity") float GroundCheckDistance = 5.f; // 지면 감지 거리
UPROPERTY(EditAnywhere, Category = "Gravity") float AirControlRatio = 0.4f; // 공중 제어 비율
// 중력 상태 변수
float VerticalVelocity = 0.f; // 현재 수직 속도
bool bIsGrounded = false; // 지면 접촉 상태
float LastElevateAxis = 0.f; // 마지막 상승/하강 입력 값
// 내부 함수
// 인공 중력 계산 및 적용
void ApplyArtificialGravity(float DeltaTime);
// 라인 트레이스 지면 확인
bool GroundCheck(FHitResult& OutHit) const; // OutHit: 충돌 결과
};
#include "MyPawn.h"
#include "Components/CapsuleComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputComponent.h"
#include "MyPlayerController.h"
// 생성자
AMyPawn::AMyPawn()
{
// 틱 함수 활성화
PrimaryActorTick.bCanEverTick = true;
// 캡슐 컴포넌트 생성
Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
Capsule->InitCapsuleSize(34.f, 88.f);
Capsule->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Capsule->SetCollisionObjectType(ECC_Pawn);
Capsule->SetCollisionResponseToAllChannels(ECR_Block);
Capsule->SetSimulatePhysics(false); // 물리 시뮬레이션 비활성화
RootComponent = Capsule; // 루트 컴포넌트로 설정
// 메시 컴포넌트 생성
Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
Mesh->SetupAttachment(RootComponent); // 루트에 부착
Mesh->SetRelativeLocation(FVector(0.f, 0.f, -88.f));
Mesh->SetRelativeRotation(FRotator(0.f, -90.f, 0.f));
Mesh->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Mesh->SetCollisionResponseToAllChannels(ECR_Ignore);
Mesh->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
Mesh->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Block);
Mesh->SetSimulatePhysics(false);
// 스프링암 컴포넌트 생성
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(RootComponent);
SpringArm->TargetArmLength = 350.f;
SpringArm->bUsePawnControlRotation = true;
SpringArm->bEnableCameraLag = true;
SpringArm->CameraLagSpeed = 10.f;
SpringArm->SetRelativeLocation(FVector(0.f, 0.f, 60.f));
// 카메라 컴포넌트 생성
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
Camera->bUsePawnControlRotation = false;
// 컨트롤러 회전값 Pawn에 적용
bUseControllerRotationYaw = true;
bUseControllerRotationPitch = true;
bUseControllerRotationRoll = false; // Roll은 수동 제어
}
// BeginPlay
void AMyPawn::BeginPlay()
{
Super::BeginPlay();
if (Mesh->IsSimulatingPhysics())
{
Mesh->SetSimulatePhysics(false);
}
// 메시 초기 회전값 저장 (롤링 기준점)
MeshBaseRelativeRot = Mesh->GetRelativeRotation();
}
// Tick
void AMyPawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 현재 롤 각도를 목표 각도까지 부드럽게 보간
CurrentRollDeg = FMath::FInterpTo(CurrentRollDeg, TargetRollDeg, DeltaTime, RollReturnSpeed);
// 보간된 롤 각도를 메시에 적용
FRotator NewRel = MeshBaseRelativeRot;
NewRel.Pitch = MeshBaseRelativeRot.Pitch + CurrentRollDeg;
Mesh->SetRelativeRotation(NewRel);
// 인공 중력 로직 호출
ApplyArtificialGravity(DeltaTime);
}
// 입력 컴포넌트 설정
void AMyPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
// 입력 바인딩
if (UEnhancedInputComponent* EnhancedInput
= Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
if (AMyPlayerController* PlayerController
= Cast<AMyPlayerController>(GetController()))
{
if (PlayerController->MoveAction)
{
EnhancedInput->BindAction(PlayerController->MoveAction
, ETriggerEvent::Triggered
, this
, &AMyPawn::Move);
}
if (PlayerController->LookAction)
{
EnhancedInput->BindAction(PlayerController->LookAction
, ETriggerEvent::Triggered
, this
, &AMyPawn::Look);
}
if (PlayerController->ElevateAction)
{
EnhancedInput->BindAction(PlayerController->ElevateAction
, ETriggerEvent::Triggered
, this
, &AMyPawn::Elevate);
}
if (PlayerController->RollAction)
{
EnhancedInput->BindAction(PlayerController->RollAction
, ETriggerEvent::Triggered
, this
, &AMyPawn::Roll);
EnhancedInput->BindAction(PlayerController->RollAction, ETriggerEvent::Completed, this, &AMyPawn::Roll);
}
}
}
}
// 이동 처리
void AMyPawn::Move(const FInputActionValue& value)
{
if (!Controller) return;
const FVector2D Axis = value.Get<FVector2D>();
const float Dt = GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.016f;
FVector Local(Axis.Y, Axis.X, 0.f);
if (Local.IsNearlyZero()) return;
// 에어 컨트롤: 지상/공중 속도 비율 적용
const float Ratio = bIsGrounded ? 1.0f : AirControlRatio;
AddActorLocalOffset(Local.GetSafeNormal() * (MoveSpeed * Ratio) * Dt, /*bSweep=*/true);
}
// 시점 회전 처리
void AMyPawn::Look(const FInputActionValue& value)
{
FVector2d LookInput = value.Get<FVector2d>();
AddControllerYawInput(LookInput.X * LookYawSpeed);
AddControllerPitchInput(LookInput.Y * LookPitchSpeed);
}
// 상하 이동 처리
void AMyPawn::Elevate(const FInputActionValue& Value)
{
const float AxisZ = Value.Get<float>();
// 상승 키 입력 상태 저장 (중력 무시용)
LastElevateAxis = AxisZ;
const float Dt = GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.016f;
if (!FMath::IsNearlyZero(AxisZ))
{
AddActorLocalOffset(FVector(0.f, 0.f, AxisZ) * (MoveSpeed * Dt), /*bSweep=*/true);
// 수직 이동 시, 지상 상태 해제
bIsGrounded = false;
}
}
// 롤 처리
void AMyPawn::Roll(const FInputActionValue& Value)
{
const float Axis = FMath::Clamp(Value.Get<float>(), -1.f, 1.f);
// 목표 롤 각도 설정 (실제 회전은 Tick에서)
TargetRollDeg = Axis * MaxRollDeg;
}
// 지면 감지 함수 (라인 트레이스)
bool AMyPawn::GroundCheck(FHitResult& OutHit) const
{
if (!GetWorld() || !Capsule) return false;
// 트레이스 시작/끝점 계산
const FVector Start = GetActorLocation();
const float HalfHeight = Capsule->GetScaledCapsuleHalfHeight();
const float TraceLen = HalfHeight + GroundCheckDistance;
const FVector End = Start - FVector(0.f, 0.f, TraceLen);
// 트레이스 파라미터 설정 (자신 제외)
FCollisionQueryParams Params(SCENE_QUERY_STAT(GroundCheck), false, this);
// 라인 트레이스 실행
const bool bHit = GetWorld()->LineTraceSingleByChannel(OutHit, Start, End, ECC_Visibility, Params);
// 충돌 및 평평한 지면일 경우 true 반환
return bHit && OutHit.ImpactNormal.Z > 0.5f;
}
// 인공 중력 적용 함수
void AMyPawn::ApplyArtificialGravity(float DeltaTime)
{
if (!GetWorld()) return;
// 상승 중이면 중력 무시, 속도 초기화
if (LastElevateAxis > 0.f)
{
bIsGrounded = false;
VerticalVelocity = 0.f;
return;
}
// 지면 확인
FHitResult GroundHit;
const bool bOnGroundNow = GroundCheck(GroundHit);
// 지상 착지 상태 처리
if (bOnGroundNow && VerticalVelocity <= 0.f)
{
bIsGrounded = true;
VerticalVelocity = 0.f;
return;
}
// 공중 상태: 중력 가속도 적용
bIsGrounded = false;
VerticalVelocity = FMath::Clamp(VerticalVelocity + GravityAccel * DeltaTime, -MaxFallSpeed, MaxFallSpeed);
// 계산된 속도로 월드 오프셋 이동
const FVector GravityDelta(0.f, 0.f, VerticalVelocity * DeltaTime);
FHitResult FallHit;
AddActorWorldOffset(GravityDelta, /*bSweep=*/true, &FallHit);
// 낙하 중 충돌 시 착지 처리
if (FallHit.bBlockingHit && VerticalVelocity < 0.f && FallHit.ImpactNormal.Z > 0.5f)
{
bIsGrounded = true;
VerticalVelocity = 0.f;
}
}
ApplyArtificialGravity 함수에서 낙하 후 충돌을 감지(FallHit.bBlockingHit)했을 때, VerticalVelocity = 0.f; 코드를 넣어주지 않았다. 그 결과, 바닥에 닿아도 수직 속도가 계속 누적되어 바닥을 뚫고 지나가는 현상이 발생했다. 착지 시 속도 초기화는 필수였다.GroundCheck에서 충돌 여부(bHit)만 확인하고 ImpactNormal을 체크하지 않았을 때 발생했다. 이 때문에 가파른 벽에 붙어서 떨어질 때도 GroundCheck가 true를 반환하여 캐릭터가 벽에 붙은 채로 미끄러지지 않는 버그가 있었다. OutHit.ImpactNormal.Z > 0.5f 조건을 추가하여 평평한 바닥만 '지면'으로 인식하도록 수정하여 해결했다.Elevate 함수로 상승 이동을 해도 Tick에서 계속 중력이 작용하니 상승력이 약해지는 문제가 있었다. ApplyArtificialGravity 함수의 맨 처음에 상승 키 입력 여부를 확인하여, 상승 중일 때는 중력 계산 로직 전체를 건너뛰도록 수정했다.| 개념 | 설명 | 비고 |
|---|---|---|
| 6-DOF | 3개의 이동 축(X, Y, Z)과 3개의 회전 축(Pitch, Yaw, Roll)을 모두 제어하는 움직임. | AddActorLocalOffset과 AddControllerInput을 조합하여 구현. |
FMath::FInterpTo | 현재 값에서 목표 값까지 지정된 속도로 부드럽게 보간하는 함수. | 애니메이션이나 시각 효과를 자연스럽게 만드는 데 매우 유용하다. |
LineTrace | 지정된 시작점에서 끝점까지 선을 그어 충돌을 감지하는 기능. | 지면 감지, 총알 궤적, 시야 확인 등 활용도가 매우 높다. |
| 인공 중력 | 물리 엔진 없이 Tick에서 직접 가속도를 적분하여 속도를 계산하고 위치를 변경하는 방식. | VerticalVelocity += Accel * DeltaTime;가 핵심 공식. |
| 에어 컨트롤 | 캐릭터의 상태(지상/공중)에 따라 이동 속도나 조작감을 다르게 만드는 게임 디자인 기법. | bIsGrounded 같은 상태 변수를 통해 간단히 구현할 수 있다. |