언리얼/스파르타 클럽

언리얼 C++ 과제 4

코드망령 2026. 6. 9. 13:23

스파르타 클럽 챕터3 과제4 진행


Pawn으로 이동하는 객체 만들기와 도전과제로 비행체 만들기입니다.

 

https://www.youtube.com/watch?v=qnovx8h6Qr8

 

Pawn에 걷기, 점프, 달리기를 추가했고, 추가적으로 Pawn클래스를 하나 더 만들어 비행체를 구현했습니다.

 

이동과 화면회전은 AddActorLocalLocation과 AddActorLocalRotation으로 구현했습니다.

Character클래스의 경우는 이동할 때 바닥이나 벽면에서 Block되었어도 미끄러지도록 구현이 되어있지만

Pawn의 경우 그런 물리로직을 직접 구현해야 했습니다.

 

SpartaPawn.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "SpartaPawn.generated.h"

class UCameraComponent;
class UCapsuleComponent;
class USkeletalMeshComponent;
class USpringArmComponent;
class USpartaMoveComponent;
struct FInputActionValue;

UCLASS()
class SPARTA3_4_API ASpartaPawn : public APawn
{
	GENERATED_BODY()

public:
	ASpartaPawn();

	UPROPERTY(EditAnywhere)
	UCapsuleComponent* Capsule;
	UPROPERTY(EditAnywhere)
	USkeletalMeshComponent* SkeletalMesh;
	UPROPERTY(EditAnywhere)
	USpringArmComponent* SpringArm;
	UPROPERTY(EditAnywhere)
	UCameraComponent* Camera;
	UPROPERTY(EditAnywhere)
	USpartaMoveComponent* MoveComponent;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Look")
	float LookSensitivity = 90.f;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Look")
	float MinPitch = -50.f;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Look")
	float MaxPitch = 40.f;

	UFUNCTION(BlueprintCallable)
	USpartaMoveComponent* GetMoveComponent() { return MoveComponent; }
	
protected:
	virtual void BeginPlay() override;
	virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

public:
	virtual void Tick(float DeltaTime) override;

private:
	FVector2D LookInput = FVector2D::ZeroVector;
	float CurrentPitch = 0.f;

	void Move(const FInputActionValue& Value);
	void Look(const FInputActionValue& Value);
	void StartJump(const FInputActionValue& Value);
	void StopJump(const FInputActionValue& Value);
	void StartSprint(const FInputActionValue& Value);
	void StopSprint(const FInputActionValue& Value);

	void UpdateLook(float DeltaTime);
};

 

SpartaPawn.cpp

#include "SpartaPawn.h"

#include "EnhancedInputComponent.h"
#include "SpartaPlayerContrller.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "SpartaMoveComponent.h"

/*
 * Pawn의 역할
 *
 * 이 클래스는 플레이어의 컴포넌트 구성과 입력 연결을 담당합니다.
 * 실제 이동, 점프, 중력, 벽 슬라이딩은 USpartaMoveComponent에서 처리합니다.
 *
 * 카메라 회전은 여기서 직접 처리합니다.
 * - 마우스 X: Pawn 전체를 좌우(Yaw) 회전
 * - 마우스 Y: SpringArm만 위아래(Pitch) 회전
 *
 * 이렇게 나누면 Capsule은 항상 똑바로 서 있고,
 * 카메라만 위아래로 움직이는 Character 같은 시점을 만들 수 있습니다.
 */
ASpartaPawn::ASpartaPawn()
{
	PrimaryActorTick.bCanEverTick = true;

	// Capsule은 RootComponent이며, 플레이어 이동 충돌을 담당합니다.
	Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
	SetRootComponent(Capsule);
	Capsule->InitCapsuleSize(42.f, 96.f);
	Capsule->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
	Capsule->SetCollisionResponseToAllChannels(ECR_Block);
	Capsule->SetSimulatePhysics(false);
	
	// SkeletalMesh는 화면에 보이는 용도만 담당하고, 충돌은 Capsule만 사용합니다.
	SkeletalMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Skeleton"));
	SkeletalMesh->SetupAttachment(Capsule);
	SkeletalMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	SkeletalMesh->SetSimulatePhysics(false);

	// SpringArm은 카메라의 위아래 회전을 담당합니다. Pawn 본체는 위아래로 기울이지 않습니다.
	SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("Spring Arm"));
	SpringArm->AttachToComponent(Capsule, FAttachmentTransformRules::KeepRelativeTransform);
	SpringArm->SetRelativeLocation(FVector(0.f, 0.f, 70.f));
	SpringArm->SetRelativeRotation(FRotator(CurrentPitch, 0.f, 0.f));
	SpringArm->TargetArmLength = 500.f;
	SpringArm->bUsePawnControlRotation = false;
	SpringArm->bDoCollisionTest = false;

	// Camera는 SpringArm 소켓에 붙어 SpringArm 끝 위치를 따라갑니다.
	Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
	Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
	Camera->SetRelativeLocation(FVector::ZeroVector);
	Camera->SetRelativeRotation(FRotator::ZeroRotator);
	Camera->bUsePawnControlRotation = false;

	// 실제 이동, 점프, 중력, 벽 슬라이딩은 MoveComponent에서 처리합니다.
	MoveComponent = CreateDefaultSubobject<USpartaMoveComponent>(TEXT("MoveComponent"));
	MoveComponent->UpdatedComponent = Capsule;

	bUseControllerRotationPitch = false;
	bUseControllerRotationYaw = false;
	bUseControllerRotationRoll = false;
}

void ASpartaPawn::BeginPlay()
{
	Super::BeginPlay();

}

void ASpartaPawn::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	UpdateLook(DeltaTime);
}


void ASpartaPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	// Pawn은 Enhanced Input을 받고, 이동 관련 입력은 MoveComponent로 전달합니다.
	if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
	{
		if (ASpartaPlayerContrller* PlayerController = Cast<ASpartaPlayerContrller>(GetController()))
		{
			EnhancedInput->BindAction(PlayerController->MoveAction, ETriggerEvent::Triggered, this, &ASpartaPawn::Move);
			EnhancedInput->BindAction(PlayerController->MoveAction, ETriggerEvent::Completed, this, &ASpartaPawn::Move);
			EnhancedInput->BindAction(PlayerController->LookAction, ETriggerEvent::Triggered, this, &ASpartaPawn::Look);
			EnhancedInput->BindAction(PlayerController->LookAction, ETriggerEvent::Completed, this, &ASpartaPawn::Look);
			EnhancedInput->BindAction(PlayerController->JumpAction, ETriggerEvent::Triggered, this, &ASpartaPawn::StartJump);
			EnhancedInput->BindAction(PlayerController->JumpAction, ETriggerEvent::Completed, this, &ASpartaPawn::StopJump);
			EnhancedInput->BindAction(PlayerController->SprintAction, ETriggerEvent::Triggered, this, &ASpartaPawn::StartSprint);
			EnhancedInput->BindAction(PlayerController->SprintAction, ETriggerEvent::Completed, this, &ASpartaPawn::StopSprint);
		}
	}
}


void ASpartaPawn::Move(const FInputActionValue& Value)
{
	// 이동 입력값만 MoveComponent에 넘기고, 실제 이동은 MoveComponent Tick에서 처리합니다.
	if (MoveComponent)
	{
		MoveComponent->SetMoveInput(Value.Get<FVector2D>());
	}
}

void ASpartaPawn::Look(const FInputActionValue& Value)
{
	// 마우스 입력값은 저장해두고 Tick에서 DeltaTime을 곱해 회전 처리합니다.
	LookInput = Value.Get<FVector2D>();
}

void ASpartaPawn::StartJump(const FInputActionValue& Value)
{
	if (MoveComponent)
	{
		MoveComponent->StartJump();
	}
}

void ASpartaPawn::StopJump(const FInputActionValue& Value)
{
	if (MoveComponent)
	{
		MoveComponent->StopJump();
	}
}

void ASpartaPawn::StartSprint(const FInputActionValue& Value)
{
	if (MoveComponent && Value.Get<bool>())
	{
		MoveComponent->StartSprint();
	}
}

void ASpartaPawn::StopSprint(const FInputActionValue& Value)
{
	if (MoveComponent)
	{
		MoveComponent->StopSprint();
	}
}

void ASpartaPawn::UpdateLook(float DeltaTime)
{
	if (LookInput.IsNearlyZero()) return;

	// 마우스 X축은 Pawn 전체를 좌우(Yaw)로 회전시킵니다.
	// 이 회전은 이동 방향에도 영향을 줍니다. Forward/Right 벡터가 같이 바뀌기 때문입니다.
	const float Yaw = LookInput.X * LookSensitivity * DeltaTime;
	AddActorLocalRotation(FRotator(0.f, Yaw, 0.f), false);

	// 마우스 Y축은 SpringArm만 위아래(Pitch)로 회전시켜 Capsule은 항상 똑바로 서 있게 합니다.
	// CurrentPitch를 제한해서 카메라가 너무 위나 아래로 꺾이지 않게 합니다.
	CurrentPitch = FMath::Clamp(
		CurrentPitch - LookInput.Y * LookSensitivity * DeltaTime,
		MinPitch,
		MaxPitch
	);

	if (SpringArm) SpringArm->SetRelativeRotation(FRotator(CurrentPitch, 0.f, 0.f));
	
}

 

SpartaMoveComponent.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PawnMovementComponent.h"
#include "SpartaMoveComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class SPARTA3_4_API USpartaMoveComponent : public UPawnMovementComponent
{
	GENERATED_BODY()

public:
	USpartaMoveComponent();

	// 기본 이동 속도입니다. Unreal 단위 기준으로 초당 이동 거리입니다.
	UPROPERTY(EditAnywhere, Category = "Move")
	float MoveSpeed = 450;
	// 달리기 입력이 켜져 있을 때 MoveSpeed에 곱해지는 값입니다.
	UPROPERTY(EditAnywhere, Category = "Move")
	float SprintMultiplier = 2.5f;
	// 점프를 시작할 때 위쪽으로 주는 초기 속도입니다.
	UPROPERTY(EditAnywhere, Category = "Jump")
	float JumpPower = 800.f;
	// 공중에 있을 때 매 프레임 아래쪽으로 누적되는 중력 가속도입니다.
	UPROPERTY(EditAnywhere, Category = "Physics")
	float Gravity = 980.f;
	// Pawn 중심에서 아래로 레이를 쏴서 바닥을 찾는 거리입니다.
	UPROPERTY(EditAnywhere, Category = "Physics")
	float GroundCheckDistance = 120.f;
	// 이 값보다 Normal.Z가 크면 걸을 수 있는 바닥이나 경사로 판단합니다.
	UPROPERTY(EditAnywhere, Category = "Physics")
	float WalkableFloorZ = 0.6f;

	// 이동 및 달리기 애니메이션을 위한 값을 반환합니다.
	UFUNCTION(BlueprintCallable)
	bool IsMove() { return MoveInput.IsNearlyZero(); }
	// 점프하는 애니메이션을 위한 값을 반환합니다.
	UFUNCTION(BlueprintCallable)
	float GetVerticalVelocity() { return VerticalVelocity; }
	// 떨어지는 애니메이션을 위한 값을 반환합니다.
	UFUNCTION(BlueprintCallable)
	bool GetIsGrounded() { return bIsGrounded; }
	// 달리는 애니메이션을 위한 값을 반환합니다.
	UFUNCTION(BlueprintCallable)
	bool GetIsSprinting() { return bIsSprinting; }
	
protected:
	virtual void BeginPlay() override;
	virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

public:
	void SetMoveInput(const FVector2D& Value);

	void StartJump();
	void StopJump();

	void StartSprint();
	void StopSprint();
private:
	// 매 Tick마다 GetOwner, FindComponent를 반복하지 않기 위해 캐싱합니다.
	UPROPERTY()
	APawn* CachedOwnerPawn = nullptr;
	UPROPERTY()
	UCapsuleComponent* CachedCapsule = nullptr;
	// 현재 밟고 있는 Actor입니다. 움직이는 발판을 따라가기 위해 사용합니다.
	UPROPERTY()
	AActor* GroundActor = nullptr;

	// Pawn의 Enhanced Input 함수에서 받은 이동 입력값을 저장합니다.
	FVector2D MoveInput = FVector2D::ZeroVector;

	// CharacterMovement를 쓰지 않기 때문에 Z축 속도를 직접 관리합니다.
	float VerticalVelocity = 0.f;
	// Capsule이 현재 바닥 위에 정확히 서기 위한 ActorLocation.Z 값입니다.
	float GroundZ =0.f;
	// 기본이동값과 달리기의 값을 저장하기 위한 변수입니다.
	float CurrentSpeed = 0.f;
	// 현재 밟고 있는 바닥의 Normal입니다. 울퉁불퉁한 바닥을 따라 이동할 때 사용합니다.
	FVector GroundNormal = FVector::UpVector;
	// 이전 프레임의 GroundActor 위치입니다. 움직인 발판의 이동량을 계산합니다.
	FVector LastGroundActorLocation = FVector::ZeroVector;
	
	bool bIsGrounded = true;
	bool bIsSprinting = false;

	// 아래 방향 LineTrace로 바닥 여부, 바닥 Normal, 밟고 있는 Actor를 갱신합니다.
	void CheckGround();

	// 입력 이동, 중력, 점프 속도를 합친 뒤 Sweep 이동과 벽면 슬라이드를 처리합니다.
	void ApplyMovement(float DeltaTime);
	// 현재 밟고 있는 움직이는 발판의 이동량만큼 Pawn을 먼저 이동시킵니다.
	void ApplyGroundActorMovement();
};

 

SpartaMoveComponent.cpp

#include "SpartaMoveComponent.h"

#include "Components/CapsuleComponent.h"
#include "GameFramework/Pawn.h"

/*
 * 이 컴포넌트의 전체 이동 원리
 *
 * 1. Pawn에서 받은 입력값을 MoveInput에 저장한다.
 * 2. Tick마다 아래 순서로 이동을 처리한다.
 *    - 이전 프레임에 밟고 있던 발판이 움직였으면 Pawn도 먼저 따라간다.
 *    - 아래로 LineTrace를 쏴서 현재 바닥 상태를 확인한다.
 *    - 입력 이동, 점프 속도, 중력을 합쳐 이번 프레임 이동량을 만든다.
 *    - Capsule을 Sweep 이동시켜 벽이나 바닥에 부딪혔는지 확인한다.
 *    - 충돌했다면 SlideAlongSurface로 벽면이나 경사면을 따라 미끄러지게 한다.
 *
 * Simulate Physics는 사용하지 않는다.
 * 대신 직접 계산한 이동량 + Sweep 충돌 검사 + 바닥 레이 + 슬라이딩으로
 * CharacterMovement와 비슷한 동작을 흉내낸다.
 */

USpartaMoveComponent::USpartaMoveComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
}

void USpartaMoveComponent::BeginPlay()
{
	Super::BeginPlay();

	CachedOwnerPawn = Cast<APawn>(GetOwner());
	if (CachedOwnerPawn)
	{
		CachedCapsule = CachedOwnerPawn->FindComponentByClass<UCapsuleComponent>();
		SetUpdatedComponent(CachedOwnerPawn->GetRootComponent());
		GroundZ = CachedOwnerPawn->GetActorLocation().Z;
	}
}

void USpartaMoveComponent::TickComponent(float DeltaTime, ELevelTick TickType,
	FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	if (!CachedOwnerPawn || !UpdatedComponent || ShouldSkipUpdate(DeltaTime))
	{
		return;
	}

	// 1. 지난 프레임에 밟고 있던 발판이 움직였다면 Pawn도 그만큼 먼저 따라갑니다.
	// CharacterMovement의 Movement Base 개념을 간단하게 흉내낸 부분입니다.
	ApplyGroundActorMovement();
	// 2. 현재 프레임 기준으로 바닥에 있는지, 바닥 Normal이 무엇인지 다시 확인합니다.
	CheckGround();
	// 3. 입력 이동, 중력, 점프, 충돌, 슬라이딩을 한 번에 처리합니다.
	ApplyMovement(DeltaTime);
}

void USpartaMoveComponent::SetMoveInput(const FVector2D& Value)
{
	MoveInput = Value;
}

void USpartaMoveComponent::StartJump()
{
	// 바닥에 있을 때만 점프를 시작합니다.
	// 점프는 실제 물리 힘이 아니라 VerticalVelocity 값을 직접 올리는 방식입니다.
	if (bIsGrounded)
	{
		VerticalVelocity = JumpPower;
		bIsGrounded = false;
	}
}

void USpartaMoveComponent::StopJump()
{
	// 점프 키를 떼면 상승 속도를 줄여서 짧은 점프가 가능하게 합니다.
	if (VerticalVelocity > JumpPower * 0.5f)
	{
		VerticalVelocity = JumpPower * 0.5f;
	}
}

void USpartaMoveComponent::StartSprint()
{
	bIsSprinting = true;
}

void USpartaMoveComponent::StopSprint()
{
	bIsSprinting = false;
}

void USpartaMoveComponent::CheckGround()
{
	if (!CachedOwnerPawn)
	{
		return;
	}

	UWorld* World = CachedOwnerPawn->GetWorld();
	if (!World)
	{
		return;
	}

	const FVector Start = CachedOwnerPawn->GetActorLocation();
	const FVector End = Start - FVector(0.f, 0.f, GroundCheckDistance);

	FHitResult Hit;
	FCollisionQueryParams Params(SCENE_QUERY_STAT(SpartaGroundCheck), false, CachedOwnerPawn);

	// CharacterMovement의 FloorCheck를 직접 흉내내는 부분입니다.
	// Pawn 중심에서 아래로 레이를 쏴서 걸을 수 있는 바닥인지 확인합니다.
	//
	// Hit.Normal.Z가 1에 가까우면 평평한 바닥이고,
	// 0에 가까우면 수직 벽입니다.
	// WalkableFloorZ 이상이면 걸을 수 있는 바닥이나 경사로 판단합니다.
	const bool bHit = World->LineTraceSingleByChannel(
		Hit,
		Start,
		End,
		ECC_Visibility,
		Params
	);

	if (bHit && Hit.Normal.Z >= WalkableFloorZ && VerticalVelocity <= 0.f)
	{
		bIsGrounded = true;
		// 바닥의 기울기를 저장해두면 이동 벡터를 바닥면에 맞춰 보정할 수 있습니다.
		GroundNormal = Hit.Normal;
		// Hit.ImpactPoint.Z는 바닥 표면 높이입니다.
		// Pawn의 ActorLocation은 Capsule 중심이므로 HalfHeight를 더해야 바닥 위에 서게 됩니다.
		GroundZ = Hit.ImpactPoint.Z + CachedCapsule->GetScaledCapsuleHalfHeight();
		// 바닥 Actor를 저장해두면, 다음 Tick에서 움직이는 발판의 이동량을 따라갈 수 있습니다.
		GroundActor = Hit.GetActor();
		if (GroundActor)
		{
			LastGroundActorLocation = GroundActor->GetActorLocation();
		}
	}
	else
	{
		bIsGrounded = false;
		GroundNormal = FVector::UpVector;
		GroundActor = nullptr;
	}
}

void USpartaMoveComponent::ApplyMovement(float DeltaTime)
{
	if (!CachedOwnerPawn || !UpdatedComponent)
	{
		return;
	}

	CurrentSpeed = bIsSprinting ? MoveSpeed * SprintMultiplier : MoveSpeed;

	// 바닥에 있을 때는 아래로 떨어지는 속도가 계속 누적되지 않도록 0으로 정리합니다.
	// 공중이면 매 프레임 중력을 빼서 위로 올라가던 속도가 줄고, 결국 아래로 떨어지게 합니다.
	if (bIsGrounded && VerticalVelocity < 0.f)
	{
		VerticalVelocity = 0.f;
	}
	else if (!bIsGrounded)
	{
		// 공중에 있을 때만 중력을 직접 적용합니다.
		VerticalVelocity -= Gravity * DeltaTime;
	}

	// MoveInput.X/Y를 Pawn의 Right/Forward 방향으로 변환해서 월드 이동량을 만듭니다.
	// X는 좌우, Y는 앞뒤 입력입니다.
	FVector WorldHorizontalDelta = CachedOwnerPawn->GetActorForwardVector() * MoveInput.Y;
	WorldHorizontalDelta += CachedOwnerPawn->GetActorRightVector() * MoveInput.X;
	WorldHorizontalDelta = WorldHorizontalDelta.GetClampedToMaxSize(1.f) * CurrentSpeed * DeltaTime;

	if (bIsGrounded)
	{
		// 바닥면에 이동 벡터를 투영합니다.
		// 이렇게 해야 울퉁불퉁한 바닥이나 경사에서 이동 방향이 바닥을 따라갑니다.
		// 즉, 바닥 안으로 파고드는 성분은 제거하고 바닥 표면을 따라가는 성분만 남깁니다.
		WorldHorizontalDelta = FVector::VectorPlaneProject(WorldHorizontalDelta, GroundNormal);
	}

	// 수평 입력 이동량과 수직 점프/중력 이동량을 합쳐 이번 프레임 최종 이동량을 만듭니다.
	const FVector WorldVerticalDelta(0.f, 0.f, VerticalVelocity * DeltaTime);
	const FVector DesiredDelta = WorldHorizontalDelta + WorldVerticalDelta;

	if (DesiredDelta.IsNearlyZero())
	{
		return;
	}

	FHitResult Hit;
	// Capsule을 Sweep 이동합니다.
	// 단순 순간이동이 아니라 이동 경로에 벽/바닥이 있으면 Hit에 충돌 정보가 들어옵니다.
	// Sweep은 물리 시뮬레이션이 아니라 충돌 쿼리입니다.
	SafeMoveUpdatedComponent(DesiredDelta, UpdatedComponent->GetComponentQuat(), true, Hit);

	if (!Hit.IsValidBlockingHit())
	{
		return;
	}

	if (Hit.Normal.Z >= WalkableFloorZ && VerticalVelocity <= 0.f)
	{
		// 충돌한 면이 걸을 수 있는 바닥이면 새로운 바닥으로 저장합니다.
		bIsGrounded = true;
		GroundNormal = Hit.Normal;
		GroundZ = Hit.ImpactPoint.Z + CachedCapsule->GetScaledCapsuleHalfHeight();
		VerticalVelocity = 0.f;
		GroundActor = Hit.GetActor();
		if (GroundActor)
		{
			LastGroundActorLocation = GroundActor->GetActorLocation();
		}
	}
	else
	{
		// 벽이나 너무 가파른 경사면에 부딪힌 경우입니다.
		// 이때는 바닥으로 보지 않아서 중력/낙하가 유지됩니다.
		bIsGrounded = false;
		GroundNormal = FVector::UpVector;
		GroundActor = nullptr;
	}

	// 벽에 딱 붙어서 멈추지 않고, 충돌한 표면을 따라 미끄러지도록 합니다.
	// 원리:
	// - 원래 이동 방향에서 벽 Normal 방향으로 들어가는 성분을 제거한다.
	// - 남은 이동량으로 벽면을 따라 이동한다.
	// Unreal의 MovementComponent가 제공하는 기본 슬라이딩 처리입니다.
	SlideAlongSurface(DesiredDelta, 1.f - Hit.Time, Hit.Normal, Hit, true);
}

void USpartaMoveComponent::ApplyGroundActorMovement()
{
	if (!bIsGrounded || !GroundActor || !UpdatedComponent)
	{
		return;
	}

	const FVector CurrentGroundActorLocation = GroundActor->GetActorLocation();
	const FVector GroundDelta = CurrentGroundActorLocation - LastGroundActorLocation;
	LastGroundActorLocation = CurrentGroundActorLocation;

	if (GroundDelta.IsNearlyZero())
	{
		return;
	}

	FHitResult Hit;
	// 플레이어 입력 이동 전에, 발판이 움직인 거리만큼 Pawn을 먼저 이동시킵니다.
	// 이 처리가 없으면 위아래로 움직이는 플랫폼이 Pawn 밑에서 빠져나가게 됩니다.
	// 즉, "발판 위에 서 있으면 발판의 월드 이동량을 Pawn에게도 적용한다"는 뜻입니다.
	SafeMoveUpdatedComponent(GroundDelta, UpdatedComponent->GetComponentQuat(), true, Hit);
}

 

SpartaAircraft.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "SpartaAircraft.generated.h"

class UCameraComponent;
class USpringArmComponent;
class UStaticMeshComponent;
struct FInputActionValue;

UCLASS()
class SPARTA3_4_API ASpartaAircraft : public APawn
{
	GENERATED_BODY()

public:
	// 6자유도 비행체 Pawn입니다.
	// 현재 회전 상태를 기준으로 전/후, 좌/우, 상/하 이동하고 Yaw/Pitch/Roll을 직접 계산합니다.
	ASpartaAircraft();

	// 비행체의 시각/충돌 루트 컴포넌트입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Components")
	UStaticMeshComponent* Mesh;

	// 카메라를 비행체 뒤에 배치하기 위한 SpringArm입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Components")
	USpringArmComponent* SpringArm;

	// 실제 플레이어 시야 카메라입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Components")
	UCameraComponent* Camera;

	// 지상 또는 일반 비행 상태에서의 기본 이동 속도입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Move")
	float MoveSpeed = 800.f;

	// 공중 상태에서 입력 이동 속도에 곱해지는 값입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Move")
	float AirControlMultiplier = 0.4f;

	// 마우스 X/Y로 적용되는 Yaw/Pitch 회전 속도입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation")
	float LookSpeed = 90.f;

	// Roll 입력에 적용되는 회전 속도입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation")
	float RollSpeed = 120.f;

	// 공중에서 매 프레임 적용할 인공 중력입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gravity")
	float Gravity = 980.f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gravity")
	bool bEnableArtificialGravity = false;

	// 아래 방향 지면 체크 거리입니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gravity")
	float GroundCheckDistance = 120.f;

	// 이 값 이상이면 착지 가능한 지면으로 판단합니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gravity")
	float WalkableFloorZ = 0.6f;

protected:
	virtual void BeginPlay() override;
	virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

public:
	virtual void Tick(float DeltaTime) override;

private:
	// MoveInput.X: 좌우, MoveInput.Y: 전후 입력입니다.
	FVector2D MoveInput = FVector2D::ZeroVector;
	// LookInput.X: Yaw, LookInput.Y: Pitch 입력입니다.
	FVector2D LookInput = FVector2D::ZeroVector;
	// 상하 이동 입력입니다. Space는 +1, Shift는 -1로 사용합니다.
	float VerticalInput = 0.f;
	// Roll 입력입니다. 마우스 휠 또는 별도 키 Axis 값으로 사용합니다.
	float RollInput = 0.f;
	// 인공 중력으로 누적되는 월드 Z축 속도입니다.
	float FallingVelocity = 0.f;
	bool bIsGrounded = false;

	void Move(const FInputActionValue& Value);
	void Look(const FInputActionValue& Value);
	void StartMoveUp(const FInputActionValue& Value);
	void StopMoveUp(const FInputActionValue& Value);
	void StartMoveDown(const FInputActionValue& Value);
	void StopMoveDown(const FInputActionValue& Value);

	void CheckGround();
	void ApplyMovement(float DeltaTime);
	void ApplyRotation(float DeltaTime);
};

 

SpartaAircraft.cpp

#include "SpartaAircraft.h"

#include "EnhancedInputComponent.h"
#include "SpartaPlayerContrller.h"
#include "Camera/CameraComponent.h"
#include "Components/StaticMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"

ASpartaAircraft::ASpartaAircraft()
{
	PrimaryActorTick.bCanEverTick = true;

	// StaticMesh를 루트로 사용해서 Sweep 충돌 검사를 받을 수 있게 합니다.
	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("AircraftMesh"));
	SetRootComponent(Mesh);
	Mesh->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
	Mesh->SetCollisionResponseToAllChannels(ECR_Block);
	Mesh->SetSimulatePhysics(false);

	// 비행체 뒤쪽 카메라입니다. 비행체가 회전하면 카메라도 함께 따라갑니다.
	SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
	SpringArm->SetupAttachment(Mesh);
	SpringArm->TargetArmLength = 600.f;
	SpringArm->bUsePawnControlRotation = false;
	SpringArm->bDoCollisionTest = false;

	Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
	Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
	Camera->bUsePawnControlRotation = false;
}

void ASpartaAircraft::BeginPlay()
{
	Super::BeginPlay();
}

void ASpartaAircraft::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (bEnableArtificialGravity)
	{
		CheckGround();
	}
	ApplyRotation(DeltaTime);
	ApplyMovement(DeltaTime);
}

void ASpartaAircraft::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
	{
		if (ASpartaPlayerContrller* PlayerController = Cast<ASpartaPlayerContrller>(GetController()))
		{
			// MoveAction(Vector2D): W/S는 전후, A/D는 좌우 이동입니다.
			EnhancedInput->BindAction(PlayerController->MoveAction, ETriggerEvent::Triggered, this, &ASpartaAircraft::Move);
			EnhancedInput->BindAction(PlayerController->MoveAction, ETriggerEvent::Completed, this, &ASpartaAircraft::Move);

			// LookAction(Vector2D): 마우스 X는 Yaw, 마우스 Y는 Pitch 회전입니다.
			EnhancedInput->BindAction(PlayerController->LookAction, ETriggerEvent::Triggered, this, &ASpartaAircraft::Look);
			EnhancedInput->BindAction(PlayerController->LookAction, ETriggerEvent::Completed, this, &ASpartaAircraft::Look);

			// JumpAction: Space 입력을 위쪽 이동으로 사용합니다.
			EnhancedInput->BindAction(PlayerController->JumpAction, ETriggerEvent::Triggered, this, &ASpartaAircraft::StartMoveUp);
			EnhancedInput->BindAction(PlayerController->JumpAction, ETriggerEvent::Completed, this, &ASpartaAircraft::StopMoveUp);

			// SprintAction: Shift 입력을 아래쪽 이동으로 사용합니다.
			EnhancedInput->BindAction(PlayerController->SprintAction, ETriggerEvent::Triggered, this, &ASpartaAircraft::StartMoveDown);
			EnhancedInput->BindAction(PlayerController->SprintAction, ETriggerEvent::Completed, this, &ASpartaAircraft::StopMoveDown);
		}
	}
}

void ASpartaAircraft::Move(const FInputActionValue& Value)
{
	MoveInput = Value.Get<FVector2D>();
}

void ASpartaAircraft::Look(const FInputActionValue& Value)
{
	LookInput = Value.Get<FVector2D>();
}

void ASpartaAircraft::StartMoveUp(const FInputActionValue& Value)
{
	if (Value.Get<bool>())
	{
		VerticalInput = 1.f;
		bEnableArtificialGravity = false;
	}
}

void ASpartaAircraft::StopMoveUp(const FInputActionValue& Value)
{
	if (VerticalInput > 0.f)
	{
		VerticalInput = 0.f;
	}
}

void ASpartaAircraft::StartMoveDown(const FInputActionValue& Value)
{
	if (Value.Get<bool>())
	{
		VerticalInput = -1.f;
		bEnableArtificialGravity = true;
	}
}

void ASpartaAircraft::StopMoveDown(const FInputActionValue& Value)
{
	if (VerticalInput < 0.f)
	{
		VerticalInput = 0.f;
	}
}

void ASpartaAircraft::CheckGround()
{
	UWorld* World = GetWorld();
	if (!World)
	{
		return;
	}

	const FVector Start = GetActorLocation();
	const FVector End = Start - FVector(0.f, 0.f, GroundCheckDistance);

	FHitResult Hit;
	FCollisionQueryParams Params(SCENE_QUERY_STAT(AircraftGroundCheck), false, this);

	const bool bHit = World->LineTraceSingleByChannel(
		Hit,
		Start,
		End,
		ECC_Visibility,
		Params
	);

	bIsGrounded = bHit && Hit.Normal.Z >= WalkableFloorZ && FallingVelocity <= 0.f;

	if (bIsGrounded)
	{
		// 착지한 순간 낙하 속도를 초기화합니다.
		FallingVelocity = 0.f;
	}
}

void ASpartaAircraft::ApplyMovement(float DeltaTime)
{
	const float ControlMultiplier = !bIsGrounded
		? AirControlMultiplier
		: 1.f;
	const float CurrentMoveSpeed = MoveSpeed * ControlMultiplier;

	// 현재 Actor 회전 기준의 로컬 축으로 이동합니다.
	FVector WorldMoveDelta = GetActorForwardVector() * MoveInput.Y;
	WorldMoveDelta += GetActorRightVector() * MoveInput.X;
	WorldMoveDelta += GetActorUpVector() * VerticalInput;
	WorldMoveDelta = WorldMoveDelta.GetClampedToMaxSize(1.f) * CurrentMoveSpeed * DeltaTime;

	if (bEnableArtificialGravity && !bIsGrounded)
	{
		// 인공 중력은 월드 아래 방향으로 적용합니다.
		FallingVelocity -= Gravity * DeltaTime;
	}

	if (bEnableArtificialGravity)
	{
		WorldMoveDelta.Z += FallingVelocity * DeltaTime;
	}

	if (WorldMoveDelta.IsNearlyZero())
	{
		return;
	}

	FHitResult Hit;
	AddActorWorldOffset(WorldMoveDelta, true, &Hit);

	if (Hit.IsValidBlockingHit())
	{
		if (bEnableArtificialGravity && Hit.Normal.Z >= WalkableFloorZ && FallingVelocity <= 0.f)
		{
			bIsGrounded = true;
			FallingVelocity = 0.f;
			return;
		}

		// 벽이나 가파른 면에 닿으면 표면을 따라 미끄러지게 이동합니다.
		const FVector RemainingDelta = WorldMoveDelta * (1.f - Hit.Time);
		const FVector SlideDelta = FVector::VectorPlaneProject(RemainingDelta, Hit.Normal);
		AddActorWorldOffset(SlideDelta, true);
	}
}

void ASpartaAircraft::ApplyRotation(float DeltaTime)
{
	if (LookInput.IsNearlyZero() && FMath::IsNearlyZero(RollInput))
	{
		return;
	}

	// 6DOF 회전은 현재 비행체 로컬 축을 기준으로 누적됩니다.
	const float PitchDelta = -LookInput.Y * LookSpeed * DeltaTime;
	const float YawDelta = LookInput.X * LookSpeed * DeltaTime;
	const float RollDelta = RollInput * RollSpeed * DeltaTime;

	AddActorLocalRotation(FRotator(PitchDelta, YawDelta, RollDelta), true);
}

 

 

'언리얼 > 스파르타 클럽' 카테고리의 다른 글

언리얼 C++과제3  (0) 2026.06.04