컨텐츠 검색
[UE5 C++] 회전 발판과 움직이는 장애물 구현하기

2026. 1. 21. 20:28Unreal Engine/개념

1. 구현 목표

이동하는 액터, 회전하는 액터, 타이머 액터, 그리고 이를 랜덤한 위치에 생성하는 스포너를 구현하여 맵에 배치합니다.

플레이어는 출발지에서 생성되어 이 장애물들을 극복하고 목적지에 도달하는 게임을 만드는 것이 목표입니다.


2. 구현 순서 및 핵심 로직

가독성을 위해 직접 생성한 C++ 클래스명은 모두 Custom으로 통일해서 글을 작성했습니다.

1) CustomPlayerController 클래스 구현

플레이어의 입력을 처리하기 위해 APlayerController를 상속받은 CustomPlayerController 클래스를 생성했습니다.

이번 프로젝트에서는 언리얼 엔진 5의 표준인 향상된 입력 시스템(Enhanced Input System)을 사용했습니다.

먼저 헤더 파일(.h)에 입력 컨텍스트(IMC)와 각종 입력 액션(IA)을 담을 변수를 선언했습니다.

// CustomPlayerController.h

public:
    // 에디터에서 할당할 입력 매핑 컨텍스트
    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* SprintAction;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
    UInputAction* LookAction;
  • UPROPERTY(EditAnywhere, ...): 이 매크로를 사용하여 C++ 코드를 수정하지 않고도 언리얼 에디터의 디테일 패널에서 만들어둔 Input Action 에셋을 직접 드래그 앤 드롭으로 할당할 수 있게 만들었습니다. 이렇게 하면 코드와 에셋 간의 결합도를 낮출 수 있어 관리가 편해집니다.

다음으로 소스 파일(.cpp)의 BeginPlay 함수에서 이 입력 컨텍스트를 활성화하는 로직을 작성했습니다.

// CustomPlayerController.cpp

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

    // 현재 이 컨트롤러에 연결된 로컬 플레이어 정보를 가져옴
    if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
    {
        // 향상된 입력 서브시스템을 가져옴
        if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
            LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
        {
            // 에디터에서 할당한 IMC가 유효하다면 시스템에 등록(Add)함
            if (InputMappingContext)
            {
                // 0은 우선순위(Priority)를 의미함
                Subsystem->AddMappingContext(InputMappingContext, 0);
            }
        }
    }
}
  • UEnhancedInputLocalPlayerSubsystem: 향상된 입력 시스템을 관리하는 로컬 플레이어의 하위 시스템입니다.
  • AddMappingContext: 단순히 변수를 선언한다고 입력이 작동하는 것이 아니라, 이 함수를 통해 "이 플레이어가 해당 매핑 컨텍스트를 사용하겠다"라고 명시적으로 등록해줘야 키 입력이 정상적으로 동작합니다.

이 과정을 통해 에디터에서 설정한 키 매핑(WASD, 스페이스바 등)이 실제 게임 로직과 연결될 준비를 마쳤습니다.


2) CustomCharacter 클래스 구현

플레이어가 실제로 조작하게 될 캐릭터 클래스입니다. ACharacter를 상속받아 구현했으며, 크게 카메라 설정, 입력 바인딩, 이동 로직으로 나누어 작성했습니다.

1. 컴포넌트 설정 (3인칭 시점 구현)

생성자에서 SpringArmComponent와 CameraComponent를 생성하고 설정하여 3인칭 뷰를 구현했습니다.

  • SpringArm: 카메라가 캐릭터를 따라다니되, 일정한 거리를 유지하고 벽에 가려지지 않도록 충돌 처리를 돕습니다.
  • Camera: 실제 화면을 렌더링하는 눈 역할을 합니다.
// CustomCharacter.cpp 생성자
ACustomCharacter::ACustomCharacter()
{
    // ... (중략) ...

    // 스프링 암 생성 및 설정
    SpringArmComp = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArmComp->SetupAttachment(RootComponent); // 루트(캡슐)에 부착
    SpringArmComp->TargetArmLength = 300.0f;       // 카메라 거리 300
    SpringArmComp->TargetOffset.Z = 150.0f;        // 높이 보정
    SpringArmComp->bUsePawnControlRotation = true; // 마우스 회전에 따라 스프링암도 회전

    // 카메라 생성 및 설정
    CameraComp = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
    CameraComp->SetupAttachment(SpringArmComp, USpringArmComponent::SocketName); // 스프링 암 끝에 부착
    CameraComp->bUsePawnControlRotation = false;   // 카메라는 스프링 암을 따라가기만 하면 됨
}

2. 향상된 입력 시스템(Enhanced Input) 바인딩

앞서 PlayerController에서 정의해둔 InputAction들을 실제 캐릭터의 함수와 연결하는 과정입니다.

  • SetupPlayerInputComponent 함수를 오버라이드하여 구현합니다.
  • Cast<UEnhancedInputComponent>를 통해 향상된 입력 컴포넌트로 변환한 뒤 BindAction 함수를 사용합니다.
  • Trigger Event를 구분하여 로직을 연결했습니다.
    • Triggered: 누르고 있는 동안 계속 호출 (이동, 시점 변환)
    • Started: 누르는 순간 호출 (점프 시작, 달리기 시작)
    • Completed: 뗐을 때 호출 (점프 중단, 달리기 중단)
// 입력 바인딩
void ACustomCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    // EnhancedInputComponent로 캐스팅
    if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        // 커스텀 컨트롤러에서 Action 변수들을 가져옴
        if (ACustomPlayerController* PlayerController = Cast<ACustomPlayerController>(GetController()))
        {
            // 이동 바인딩
            if (PlayerController->MoveAction)
            {
                EnhancedInputComponent->BindAction(
                    PlayerController->MoveAction, 
                    ETriggerEvent::Triggered, 
                    this, 
                    &ACustomCharacter::Move
                );
            }

            // ... 점프, 달리기, 시점 변환 바인딩 ...
        }
    }
}

3. 이동 및 달리기 로직 구현

  • 이동(Move): 입력받은 2D 벡터(WASD)를 기준으로 캐릭터가 바라보는 방향(Forward)과 오른쪽 방향(Right)으로 힘을 가해 움직입니다.
  • 달리기(Sprint): Started와 Completed 이벤트를 활용하여, 키를 누르고 있을 때만 CharacterMovementComponent의 MaxWalkSpeed를 증가시키는 방식으로 구현했습니다.
void ARITSCharacter::StartSprint(const FInputActionValue& value)
{
    // Shift 키를 누르면 속도 증가
    if (GetCharacterMovement())
    {
        GetCharacterMovement()->MaxWalkSpeed = SprintSpeed;
    }
}

void ARITSCharacter::StopSprint(const FInputActionValue& value)
{
    // 떼면 원래 속도로 복구
    if (GetCharacterMovement())
    {
        GetCharacterMovement()->MaxWalkSpeed = NormalSpeed;
    }
}

4. 낙사 처리 (FellOutOfWorld)

월드 설정의 KillZ 아래로 떨어졌을 때 호출되는 FellOutOfWorld 함수를 오버라이드하여, 게임 모드에게 리스폰을 요청하도록 연결했습니다.


3) CustomGameMode 및 리스폰 시스템 구현

AGameModeBase를 상속받은 CustomGameMode를 생성하여 게임의 주요 규칙을 정의했습니다.

이 클래스는 두 가지 핵심 역할을 수행합니다.

1. 기본 클래스(Default Classes) 등록

게임이 시작될 때 어떤 캐릭터와 컨트롤러를 사용할지 지정해야 합니다. 생성자에서 DefaultPawnClass와 PlayerControllerClass를 앞서 만든 커스텀 클래스로 지정하여, 별도의 블루프린트 설정 없이도 직접 만든 캐릭터로 게임이 시작되도록 설정했습니다.

// CustomGameMode.cpp 생성자
ACustomGameMode::ACustomGameMode()
{
    // 커스텀 캐릭터와 컨트롤러를 기본값으로 설정
    DefaultPawnClass = ACustomCharacter::StaticClass();
    PlayerControllerClass = ACustomPlayerController::StaticClass();
}

2. 리스폰(부활) 시스템 구현

캐릭터가 FellOutOfWorld 등을 통해 죽었을 때, 즉시 부활하지 않고 일정 시간 뒤에 PlayerStart 지점에서 부활하는 로직을 구현했습니다.

  • RequestRespawn: 캐릭터로부터 호출되는 함수입니다.
    1. 기존 캐릭터(Pawn)를 파괴(Destroy)합니다.
    2. 컨트롤러의 빙의를 해제(UnPossess)하여 유령 상태로 만듭니다.
    3. FTimerDelegate를 사용해 2초 뒤에 실행될 타이머를 예약합니다. 이때, 누구를 부활시킬지 알아야 하므로 Controller 정보를 파라미터로 넘겨주는 방식을 사용했습니다.
  • OnRespawnTimerElapsed: 타이머가 종료되면 호출됩니다.
    • 언리얼 엔진 내장 함수인 RestartPlayer(Controller)를 호출하면, 엔진이 알아서 레벨에 배치된 PlayerStart 액터를 찾아 그 위치에 캐릭터를 다시 스폰하고 컨트롤러를 연결해 줍니다.
void ACustomGameMode::RequestRespawn(ACustomPlayerController* Controller)
{
    if (!Controller) return;

    // 1. 기존 캐릭터 제거 및 빙의 해제
    APawn* ControlledPawn = Controller->GetPawn();
    if (ControlledPawn)
    {
        ControlledPawn->Destroy();
    }
    Controller->UnPossess();

    // 2. 2초 뒤 리스폰 함수 호출 (델리게이트로 파라미터 전달)
    FTimerHandle RespawnTimerHandle;
    FTimerDelegate RespawnDelegate;
    RespawnDelegate.BindUObject(this, &ACustomGameMode::OnRespawnTimerElapsed, Controller);

    GetWorldTimerManager().SetTimer(RespawnTimerHandle, RespawnDelegate, 2.0f, false);
}

void ACustomGameMode::OnRespawnTimerElapsed(ACustomPlayerController* Controller)
{
    if (Controller)
    {
        // 3. PlayerStart 위치에서 재시작
        RestartPlayer(Controller);
    }
}

이 구조를 통해 플레이어는 사망 후 잠시 대기 시간을 가진 뒤 안전한 시작 지점에서 게임을 이어갈 수 있게 됩니다.

 


4) CustomMoveActor (이동 발판) 구현

플레이어가 밟고 지나가거나 장애물로 작용할 수 있는 움직이는 발판 클래스입니다. AActor를 상속받았으며, 지정된 방향으로 일정 거리만큼 이동하다가 되돌아오는 왕복 운동을 구현했습니다.

1. 주요 속성 및 초기화

헤더 파일에서 MoveSpeed(이동 속도)와 MaxRange(최대 이동 거리)를 EditAnywhere 속성으로 선언하여, 레벨 디자이너가 에디터에서 자유롭게 값을 조정할 수 있도록 만들었습니다.

BeginPlay에서는 현재 액터의 위치를 StartLocation에 저장하여 이동의 기준점으로 삼았습니다. 특히 이동 방향을 GetActorForwardVector()로 설정한 점이 중요합니다.

이렇게 하면 코드 상에서 X, Y, Z축을 고정하지 않고, 에디터에서 액터를 회전시키는 것만으로 이동 경로를 자유롭게 바꿀 수 있습니다.

// CustomMoveActor.cpp 초기화
void ACustomMoveActor::BeginPlay()
{
    Super::BeginPlay();

    // 시작 위치 기억 (반환점 계산의 기준)
    StartLocation = GetActorLocation();
    // 에디터에서 바라보는 방향(Forward)을 이동 방향으로 설정
    MoveDirection = GetActorForwardVector();
}

2. 이동 로직 (Tick 함수)

매 프레임 호출되는 Tick 함수에서 실제 위치 변경을 처리합니다.

  1. 위치 갱신: 현재 위치에 (방향 * 속도 * DeltaTime)을 더해 새로운 위치를 계산하고 SetActorLocation으로 적용합니다. DeltaTime을 곱해주어 프레임 레이트에 상관없이 일정한 속도로 움직이게 했습니다.
  2. 거리 체크 및 반전: FVector::Dist 함수를 사용해 시작점(StartLocation)과 현재 위치 사이의 거리를 구합니다. 이 거리가 MaxRange보다 커지면 MoveDirection에 -1을 곱해 이동 방향을 반대로 뒤집습니다.
void ACustomMoveActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 1. 이동 처리
    FVector CurrentLocation = GetActorLocation();
    FVector NewLocation = CurrentLocation + (MoveDirection * MoveSpeed * DeltaTime);
    SetActorLocation(NewLocation);

    // 2. 범위 이탈 체크
    // 시작점으로부터 얼마나 멀어졌는지 계산
    Dist = FVector::Dist(StartLocation, NewLocation);

    // 최대 범위를 넘어서면 방향 반전
    if (Dist >= MaxRange)
    {
        MoveDirection = -MoveDirection;
    }
}

이 로직을 통해 발판은 시작점을 기준으로 MaxRange 반경 내를 계속해서 왕복하게 됩니다.


5) CustomRotationActor (회전 발판) 구현

이동 발판과 더불어 플레이어의 진로를 방해하거나, 타이밍에 맞춰 밟아야 하는 회전형 장애물을 구현했습니다. AddActorLocalRotation 함수를 사용하여 매 프레임 일정 각도만큼 회전하도록 만들었습니다.

1. 에디터 노출 변수 설정 (속도 및 방향 제어)

이동 발판과 마찬가지로 기획 의도에 따라 에디터에서 쉽게 수치를 조절할 수 있도록 변수를 설정했습니다.

  • RotationSpeed: 회전 속도를 조절합니다.
  • bReverseRotation: 체크 박스(bool) 하나로 정방향/역방향 회전을 쉽게 제어할 수 있도록 만들었습니다. 이를 통해 코드를 수정하지 않고도 서로 반대로 도는 톱니바퀴 등을 쉽게 배치할 수 있습니다.
// 헤더 파일 (.h)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation")
float RotationSpeed = 40.0f; // 기본 회전 속도

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation")
bool bReverseRotation = false; // 역방향 여부 체크

2. 회전 로직 (Tick 함수)

Tick 함수 내에서 DeltaTime을 곱해 프레임 레이트와 무관하게 일정한 속도로 회전하도록 구현했습니다.

  • 방향 처리: bReverseRotation이 true라면 회전값(RotateValue)에 -1.0f를 곱해 반대 방향으로 회전하게 만듭니다.
  • 회전 적용: FRotator를 생성하여 AddActorLocalRotation에 전달합니다.
    • 코드에서는 FRotator(0.0f, 0.0f, RotateValue)를 사용하여 Roll(X축) 기준으로 회전하도록 설정했습니다.
// 소스 파일 (.cpp)
void ACustomRotateActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 1. 회전량 계산 (속도 * 델타타임)
    float RotateValue = RotationSpeed * DeltaTime;

    // 2. 역방향 체크
    if (bReverseRotation)
    {
        RotateValue *= -1.0f;
    }

    // 3. 회전값 생성 (여기서는 Roll 축 회전)
    // FRotator 순서: (Pitch(Y), Yaw(Z), Roll(X))
    FRotator NewRotation = FRotator(0.0f, 0.0f, RotateValue);

    // 4. 로컬 회전 적용
    AddActorLocalRotation(NewRotation);
}

이 로직을 통해 레벨에 배치된 액터는 게임 시작과 동시에 설정된 속도와 방향으로 끊임없이 회전하게 됩니다.


6) CustomTimerActor (사라지는 발판) 구현

일정 시간마다 나타났다 사라지기를 반복하는 발판입니다. 플레이어가 타이밍을 맞춰 건너가야 하는 퍼즐 요소를 더하기 위해 FTimerHandle을 활용하여 구현했습니다.

1. 재귀적 타이머 구조 (Recursive Timer)

별도의 Tick 함수를 사용하지 않고, 함수가 자기 자신을 다시 호출하도록 예약하는 방식으로 무한 루프를 구현했습니다.

  • ScheduleStateChange 함수는 현재 상태(보임/숨김)에 따라 VisibleDuration 혹은 HiddenDuration만큼 대기 시간을 계산합니다.
  • SetTimer를 통해 대기 시간이 지나면 람다(Lambda) 함수가 실행되도록 설정했습니다.
  • 람다 함수 내부에서는 [상태 변경 -> 다음 타이머 예약] 과정을 수행하여 끊임없이 깜빡이도록 만들었습니다.

2. 렌더링과 물리 충돌 동기화

단순히 눈에 안 보인다고 해서 없는 물체가 되는 것은 아닙니다. SetActorHiddenInGame(true)만 호출하면 투명한 발판이 되어버립니다. 따라서 SetActorEnableCollision 함수도 함께 호출하여, 눈에 보이지 않을 때는 물리적으로도 밟을 수 없게 충돌 없음으로 처리했습니다.

// 람다식 내부 로직
StrongThis->SetActorHiddenInGame(!bShow);    // 렌더링 On/Off
StrongThis->SetActorEnableCollision(bShow);  // 물리 충돌 On/Off
StrongThis->ScheduleStateChange(!bShow);     // 반대 상태로 다음 타이머 예약

3. 람다 캡처와 안전성 (Weak Pointer)

타이머가 동작하는 도중에 레벨이 변경되거나 액터가 파괴될 경우, 타이머 콜백 함수가 유효하지 않은 메모리(this)에 접근하면 게임이 강제 종료(Crash)될 수 있습니다. 이를 방지하기 위해 TWeakObjectPtr를 사용했습니다.

  • WeakThis.Get()을 통해 액터가 메모리에 살아있는지 먼저 확인합니다 (isValid).
  • 살아있을 때만(StrongThis) 로직을 수행하도록 방어 코드를 작성하여 안정성을 높였습니다.
void ATimerFloorActor::ScheduleStateChange(bool bShow)
{
    // ... 시간 계산 ...
    TWeakObjectPtr<ATimerFloorActor> WeakThis(this); // Weak Pointer 생성

    TimerManager.SetTimer(BlinkTimerHandle, [WeakThis, bShow]()
    {
        // 액터가 유효한지 검사 후 실행
        if (ATimerFloorActor* StrongThis = WeakThis.Get())
        {
            // ... 상태 변경 로직 ...
        }
    }, WaitTime, false);
}

또한 EndPlay 함수에서 ClearTimer를 호출하여 액터가 사라질 때 타이머도 확실하게 정리해주었습니다.

 


7) RandomSpawner (랜덤 생성기) 구현

이동, 회전, 타이머 발판 등 앞서 만든 다양한 장애물들을 맵의 특정 구역에 무작위로 배치하는 생성기(Spawner)입니다. 레벨 디자이너가 일일이 발판을 배치하는 수고를 덜고, 매번 다른 플레이 경험을 제공하기 위해 구현했습니다.

1. 스폰 영역 지정 (UBoxComponent)

스포너 액터의 루트 컴포넌트로 UBoxComponent를 사용하여, 에디터 상에서 시각적으로 생성 범위를 조절할 수 있게 만들었습니다. GetRandomPointInVolume 함수에서는 UKismetMathLibrary::RandomPointInBoundingBox를 사용하여 이 박스 영역 내부의 임의의 좌표를 반환받습니다.

2. 다형성을 활용한 랜덤 클래스 선택

TArray<TSubclassOf<AActor>> 타입의 SpawnableClasses 배열을 선언하여, 에디터에서 여러 종류의 액터(이동 발판, 회전 발판 등)를 목록에 추가할 수 있게 설계했습니다. 코드에서는 이 배열의 인덱스를 랜덤으로 선택(FMath::RandRange)하여, 매번 다른 종류의 장애물이 생성되도록 구현했습니다.


3. 결과