← 모든 글

Unreal C++를 이용한 플랫폼 게임 2 - 움직이는 발판 만들기

회전, 임의 이동, 경유지 순환 오브젝트와 일정 시간마다 생성되고 사라지는 발판을 Unreal C++ Actor로 구현한 과정을 정리했습니다.

Unreal Engine 5 C++Unreal Engine 5ActorMoving PlatformTickTimerSpawnActor

서로 다른 방식으로 움직이는 발판 만들기

이번 Unreal C++ 실습에서는 플레이어가 통과할 코스에 여러 종류의 발판을 배치했다. 같은 AActor를 기반으로 하지만 필요한 움직임에 따라 클래스를 나눴다.

각 클래스는 메시와 이동에 필요한 값만 가지고 있다. 하나의 거대한 발판 클래스에 이동 모드를 모두 넣는 대신 동작별로 분리했기 때문에, 레벨에서는 필요한 Actor를 골라 배치하고 프로퍼티만 조정할 수 있다.

Actor의 기본 컴포넌트 구성

움직이는 오브젝트들은 공통적으로 USceneComponent를 루트로 만들고, 그 아래에 UStaticMeshComponent를 붙인다.

SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
SetRootComponent(SceneRoot);

StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
StaticMesh->SetupAttachment(SceneRoot);

PrimaryActorTick.bCanEverTick = true;

SceneRoot는 Actor의 위치와 회전을 담당하고, StaticMesh는 화면에 보이는 형상과 충돌을 담당한다. 이동 클래스는 매 프레임 값을 갱신해야 하므로 Tick을 활성화했다.

회전하는 오브젝트

ARotatingObject는 Tick()에서 이번 프레임에 회전할 각도를 계산한다.

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

    if (FMath::IsNearlyZero(DeltaTime))
        return;

    FQuat Delta(
        FVector::UpVector,
        FMath::DegreesToRadians(RotationSpeed * DeltaTime));

    AddActorLocalRotation(Delta);
}

RotationSpeed는 초당 회전 각도다. 여기에 DeltaTime을 곱하면 현재 프레임에 적용할 각도가 된다.

핵심: DeltaTime으로 프레임 속도 차이 없애기

DeltaTime은 직전 프레임부터 현재 프레임까지 흐른 시간을 초 단위로 나타낸 값이다. Tick(float DeltaTime)은 매 프레임 호출되지만 컴퓨터 성능과 순간적인 부하에 따라 호출 간격은 달라질 수 있다.

이때 매 프레임 고정된 값을 더하면 게임의 속도가 FPS에 종속된다.

// 프레임마다 3도 회전: FPS가 높을수록 더 빠르게 돈다.
AddActorLocalRotation(FRotator(0.0f, 3.0f, 0.0f));

30 FPS에서는 1초에 약 90도 회전하지만, 120 FPS에서는 같은 1초 동안 약 360도 회전한다. 플레이하는 환경에 따라 발판의 난이도가 완전히 달라지는 코드다.

반면 속도를 초당 변화량으로 정하고 DeltaTime을 곱하면 이번 프레임에 적용할 양을 구할 수 있다.

이번 프레임의 변화량 = 초당 변화량 × DeltaTime

예를 들어 RotationSpeed가 초당 90도라면 다음과 같이 계산된다.

한 프레임에 적용하는 양은 달라도 1초 동안 누적되는 회전은 약 90도로 같다. 따라서 아래 계산에서 DeltaTime은 단순한 보정값이 아니라 RotationSpeed를 초당 속도로 사용할 수 있게 만드는 핵심 값이다.

FMath::DegreesToRadians(RotationSpeed * DeltaTime)

같은 원리는 뒤에서 구현하는 이동 발판에도 적용된다.

FVector Delta = Direction * MoveSpeed * DeltaTime;

여기서 Direction은 이동 방향, MoveSpeed는 초당 이동 거리, DeltaTime은 이번 프레임에 흐른 시간이다. 세 값을 곱한 Delta가 현재 프레임에 실제로 이동할 거리다.

이 글의 회전, 임의 이동, 경유지 순환 코드는 모두 DeltaTime을 사용한다. 덕분에 FPS가 달라도 발판이 같은 시간 기준으로 움직인다.

회전은 쿼터니언인 FQuat으로 만들었다. 축은 FVector::UpVector이므로 Z축을 중심으로 돌며, AddActorLocalRotation()으로 현재 로컬 회전에 누적한다.

캐릭터 앞에서 회전하고 있는 파란색 발판

실행 화면에서는 긴 파란색 발판이 Z축을 중심으로 계속 회전한다. 발판의 회전 속도는 에디터에 노출된 RotationSpeed로 조절할 수 있으므로, 같은 클래스를 사용하더라도 코스마다 다른 난이도를 줄 수 있다.

시작점 주변을 임의로 이동하는 오브젝트

ATranslationObject는 게임이 시작될 때 현재 위치를 저장하고 첫 목적지를 정한다.

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

    StartLocation = GetActorLocation();
    SetNextDestination();
}

목적지는 시작점에서 MaxRange 안에 있는 임의의 점이다.

void ATranslationObject::SetNextDestination()
{
    float RandomRange = FMath::RandRange(0.0f, MaxRange);
    FVector2D RandomCircle = FMath::RandPointInCircle(RandomRange);
    FVector RandomCircle3D(RandomCircle.X, RandomCircle.Y, 0.0f);

    Destination = StartLocation + RandomCircle3D;
    Direction = Destination - StartLocation;
}

RandPointInCircle()의 결과는 2차원 좌표이므로 Z를 0으로 둔 FVector로 바꾼다. 따라서 오브젝트는 높이를 유지한 채 XY 평면에서 이동한다.

매 프레임에는 현재 위치에서 목적지로 향하는 방향을 구하고 정규화한다.

Direction = Destination - GetActorLocation();
Direction.Normalize();

FVector Delta = Direction * MoveSpeed * DeltaTime;
AddActorLocalOffset(Delta);

목적지까지의 남은 거리가 StopDistance 이하가 되면 도착했다고 판단하고 다음 임의 목적지를 만든다. 정확히 같은 좌표가 될 때까지 기다리지 않고 허용 거리를 둔 이유는 한 프레임의 이동량 때문에 목적지를 지나쳐 왕복하는 현상을 피하기 위해서다.

현재 StopDistance는 에디터에서 직접 지정한다. 이동 속도가 빨라지면 한 프레임에 이동하는 거리도 커지므로, 고정된 값만으로는 목적지를 지나칠 가능성이 있다. 추후에는 MoveSpeed * DeltaTime으로 구한 이번 프레임의 이동 거리를 기준으로 도착 여부를 판단하거나, 남은 이동량이 목적지까지의 거리보다 클 때 목적지에 정확히 맞춰 정지하도록 개선할 필요가 있다.

플레이어 앞에 배치된 회전 오브젝트와 임의 이동 오브젝트

실행 화면에서는 초록색 판과 빨간색 구체처럼 서로 다른 메시에도 같은 이동 구조를 적용할 수 있다. Actor의 이동과 회전은 루트 컴포넌트에 적용되고 외형은 자식 StaticMesh가 담당하기 때문에, 동작 코드를 바꾸지 않고 메시와 머티리얼을 교체할 수 있다.

등록한 지점을 순환하는 오브젝트

AReciprocatingObject는 에디터에서 지정한 Locations 배열을 경유지로 사용한다.

FVector CurrentLocation = GetActorLocation();
FVector CurrentDestination = Locations[CurrentIndex];
FVector Direction = CurrentDestination - CurrentLocation;
Direction.Normalize();

FVector Delta = Direction * MovingSpeed * DeltaTime;
AddActorLocalOffset(Delta);

현재 목적지에 도착하면 다음 인덱스로 넘어간다.

if ((CurrentDestination - GetActorLocation()).Length() <= StopDistance)
{
    CurrentIndex = (CurrentIndex + 1) % Locations.Num();
}

나머지 연산자를 사용했기 때문에 마지막 위치 다음에는 다시 0번 위치로 돌아간다. 위치가 두 개라면 두 지점을 왕복하고, 세 개 이상이면 등록된 순서대로 계속 순환한다.

하늘에 떠서 지정된 경로를 이동하는 빨간색 발판

이동 발판은 공중에 배치한 여러 위치를 차례대로 향한다. 플레이어에게는 같은 발판처럼 보이지만 내부에서는 현재 목적지의 인덱스를 바꾸면서 반복 경로를 만든다. 레벨 디자이너는 Locations, MovingSpeed, StopDistance를 조정해 이동 범위와 속도를 바꿀 수 있다.

이 클래스는 Locations가 비어 있으면 Tick()에서 잘못된 인덱스에 접근할 수 있다. BeginPlay()에서 오류 로그를 남기고 있지만 Tick 자체는 계속 실행되므로, 실제로 안전하게 만들려면 Tick에서도 배열이 비었는지 확인하거나 SetActorTickEnabled(false)로 갱신을 중단하는 편이 좋다.

여러 위치에 발판 생성하기

ABoxSpawner는 TSubclassOf<APlatform>으로 생성할 발판 클래스를 받는다. C++ 기본 클래스뿐 아니라 이를 상속한 Blueprint 클래스도 에디터에서 지정할 수 있다.

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="BoxSpawner|Properties")
TSubclassOf<APlatform> Platform;

BeginPlay()에서는 첫 타이머를 등록하고, 시간이 지나면 SpawnBox()를 호출한다.

GetWorld()->GetTimerManager().SetTimer(
    TimerHandle,
    this,
    &ABoxSpawner::SpawnBox,
    SpawnDelay,
    false);

마지막 인자의 false는 반복 타이머가 아니라 한 번만 실행되는 타이머라는 뜻이다. 발판을 만든 뒤 다음 타이머를 다시 등록하면서 생성 흐름을 이어간다.

void ABoxSpawner::SpawnBox()
{
    FVector CurrentLocation = Locations[CurrentLocationIndex];
    FRotator Rotator(0.0, 0.0, 0.0);

    GetWorld()->SpawnActor<APlatform>(Platform, CurrentLocation, Rotator);

    if (CurrentLocationIndex == Locations.Num() - 1)
    {
        SetDelayTimer();
    }
    else
    {
        CurrentLocationIndex++;
        SetSpawnTimer();
    }
}

배열의 마지막 위치까지 생성하면 람다 타이머가 인덱스를 0으로 되돌린다. 그 뒤 다시 생성 타이머를 등록하므로 발판 묶음이 반복해서 나타난다.

if (CurrentLocationIndex == Locations.Num() - 1)
{
    SetDelayTimer();
}

마지막 위치, 즉 꼭대기 발판을 생성한 직후에는 바로 다음 순환을 시작하지 않고 SetDelayTimer()를 호출한다.

void ABoxSpawner::SetDelayTimer()
{
    GetWorld()->GetTimerManager().SetTimer(
        TimerHandle,
        [this]()
        {
            CurrentLocationIndex = 0;
            SetSpawnTimer();
        },
        SpawnDelay,
        false);
}

SetDelayTimer()가 한 번 기다린 뒤 SetSpawnTimer()를 다시 등록하므로, 꼭대기 발판과 다음 순환의 첫 발판 사이에는 평소보다 긴 간격이 생긴다. 모든 발판이 같은 간격으로 끝없이 생성되는 대신 한 묶음이 끝났다는 것을 플레이어가 느낄 수 있도록 의도적으로 쉼을 넣은 것이다.

발판이 순서대로 생성되는 모습

아래 세 화면은 같은 위치 목록을 따라 발판이 생성되고 사라지는 과정을 시간 순서대로 촬영한 것이다.

여러 위치에 파란색 발판이 모두 생성된 첫 번째 장면

첫 장면에서는 바닥에서 위쪽 초록색 도착 지점까지 여러 발판이 이어져 있다. ABoxSpawner가 Locations 배열의 좌표를 하나씩 사용해 만든 상태다.

일부 파란색 발판만 남아 있는 두 번째 장면

시간이 지나 먼저 생성된 발판부터 자신의 DestroyDelay에 도달해 사라진다. Spawner의 생성 타이머와 각 Platform의 제거 타이머가 독립적으로 움직이기 때문에 발판의 배열이 계속 달라진다.

사라진 발판이 아래쪽부터 다시 생성되는 세 번째 장면

마지막 위치까지 처리한 Spawner는 CurrentLocationIndex를 0으로 되돌린다. 이후 아래쪽 좌표부터 발판을 다시 생성하면서 같은 순서를 반복한다. 플레이어는 발판이 나타나 있는 시간을 보고 이동 시점을 선택해야 한다.

생성된 발판의 수명 관리

APlatform은 생성되었을 때 자신의 파괴 타이머를 시작한다.

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

    GetWorld()->GetTimerManager().SetTimer(
        TimerHandle,
        this,
        &APlatform::DestroySelf,
        DestroyDelay);
}

void APlatform::DestroySelf()
{
    Destroy();
}

Spawner가 모든 발판을 추적하며 제거하지 않아도 각 발판이 자신의 수명을 관리한다. 생성 책임은 ABoxSpawner, 사라지는 시점은 APlatform에 나뉜 셈이다.

Spawner는 EndPlay()에서 자신에게 연결된 타이머를 정리한다.

void ABoxSpawner::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    GetWorld()->GetTimerManager().ClearAllTimersForObject(this);
    Super::EndPlay(EndPlayReason);
}

레벨이 종료되거나 Actor가 제거된 뒤 예약된 콜백이 남지 않도록 수명 주기에 맞춰 타이머를 정리하는 부분이다.

정리

이번 발판 구현에서는 두 가지 갱신 방식을 사용했다. 연속적인 위치와 회전 변화는 Tick()에서 DeltaTime을 이용해 계산했고, 일정 시간 뒤 한 번 실행할 생성과 제거는 TimerManager에 맡겼다.

또한 이동 방식, 생성, 수명 관리를 서로 다른 Actor로 분리했다. 덕분에 레벨에서는 코드를 다시 컴파일하지 않고도 메시, 속도, 경유지, 생성 간격과 유지 시간을 조합해 여러 형태의 코스를 만들 수 있다.

남은 작업

현재 이동 오브젝트들은 모두 SceneRoot, StaticMesh, Tick 활성화 코드처럼 비슷한 구성을 반복한다. 다음에는 이 공통 부분을 부모 발판 클래스로 묶고, 회전·임의 이동·경유지 순환 클래스가 이를 상속하도록 정리할 계획이다.

다만 부모 클래스에는 모든 이동 방식을 한꺼번에 넣지 않고, 공통 컴포넌트와 초기화처럼 실제로 공유되는 책임만 둬야 한다. 각 파생 클래스는 지금처럼 자신에게 필요한 이동 데이터와 갱신 로직만 가지도록 유지한다.