[DAY39] Creating a dynamic puzzle stage

베리투스·2025년 9월 26일

TIL: Today I Learned

목록 보기
40/93

오늘은 언리얼 엔진에서 C++를 사용하여 동적인 퍼즐 스테이지를 만드는 과제를 진행했다. 움직이는 발판, 회전하는 발판, 그리고 나타났다 사라지는 발판을 각각의 C++ Actor 클래스로 구현했다. 또한, 이들을 게임 시작 시점에 랜덤한 위치와 속성으로 자동 생성하는 Spawner를 만들어 절차적 레벨 생성의 기초를 다졌다. 이 과정을 통해 UPROPERTY로 C++ 코드를 에디터와 연결하고, TickTimer를 활용한 동적 로직 구현, Cast를 이용한 객체 타입 확인 등 핵심적인 개념들을 체득할 수 있었다. 🚀


📌 목표

  • C++ AActor 클래스를 상속받아 여러 종류의 게임 오브젝트 만들기
  • Tick 함수와 DeltaTime을 이용한 프레임 독립적인 움직임 구현하기
  • FTimerManager를 활용한 시간 기반의 이벤트 처리 로직 구현하기
  • UPROPERTY 매크로를 사용해 C++ 변수를 언리얼 에디터에서 제어하기
  • SpawnActorCast를 활용하여 동적으로 액터를 생성하고 속성을 제어하는 Spawner 만들기

📖 이론

1. AActor와 UPROPERTY

AActor는 언리얼 월드에 배치될 수 있는 모든 오브젝트의 기본 클래스이다. 우리가 레벨에 놓는 의자, 캐릭터, 발판 모두 액터에 해당한다. C++에서 AActor를 상속받아 우리만의 기능을 가진 클래스를 만들 수 있다.

UPROPERTY는 C++ 클래스의 멤버 변수를 언리얼 엔진의 리플렉션 시스템에 등록하는 매크로이다. 이 매크로 덕분에 C++ 코드에 선언한 변수를 에디터의 Details 패널에서 직접 보고 수정할 수 있게 된다. EditAnywhere, VisibleAnywhere 같은 지정자를 통해 노출 방식과 권한을 세밀하게 제어할 수 있다.

2. Tick과 Timer

Tick(float DeltaTime) 함수는 매 프레임마다 호출되는 특별한 함수이다. 주로 연속적인 움직임이나 상태 변화를 구현할 때 사용한다. 여기서 DeltaTime은 이전 프레임과 현재 프레임 사이의 시간 간격으로, 이를 곱해주어야 컴퓨터 성능(프레임 속도)에 관계없이 일정한 속도로 움직이는 프레임 독립적인 로직을 만들 수 있다.

GetWorld()->GetTimerManager()Tick보다 훨씬 효율적으로 시간 기반 이벤트를 처리하는 시스템이다. "N초 후에 이 함수를 한 번 실행해줘" 또는 "N초마다 이 함수를 반복 실행해줘" 와 같은 예약 작업을 걸 수 있다. 매 프레임 체크할 필요가 없는 로직에 사용하면 성능을 크게 아낄 수 있다.


💻 코드

TimedPlatform.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TimedPlatform.generated.h"

UCLASS()
class HOMEWORK6_API ATimedPlatform : public AActor
{
    GENERATED_BODY()

public:
    ATimedPlatform();

protected:
    virtual void BeginPlay() override;

public:
    // 플랫폼의 시각적 외형을 담당하는 스태틱 메시 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    UStaticMeshComponent* MeshComponent;

    // 플랫폼이 나타나 있다가 사라지기까지 걸리는 시간(초)
    UPROPERTY(EditAnywhere, Category = "Platform Settings")
    float DisappearDelay = 3.0f;

    // 플랫폼이 사라져 있다가 다시 나타나기까지 걸리는 시간(초)
    UPROPERTY(EditAnywhere, Category = "Platform Settings")
    float ReappearDelay = 2.0f;

private:
    // 사라지는 타이머를 제어하기 위한 핸들
    FTimerHandle DisappearTimerHandle;
    // 다시 나타나는 타이머를 제어하기 위한 핸들
    FTimerHandle ReappearTimerHandle;

    // 플랫폼을 사라지게 하는 함수 (타이머에 의해 호출됨)
    UFUNCTION()
    void Disappear();

    // 플랫폼을 다시 나타나게 하는 함수 (타이머에 의해 호출됨)
    UFUNCTION()
    void Reappear();

    // 플랫폼의 가시성과 충돌 상태를 한 번에 제어하는 헬퍼 함수
    void SetPlatformState(bool bIsVisible);
};

TimedPlatform.cpp

#include "TimedPlatform.h"
#include "UObject/ConstructorHelpers.h"

ATimedPlatform::ATimedPlatform()
{
    // 타이머를 사용하므로 Tick을 비활성화하여 성능을 최적화
    PrimaryActorTick.bCanEverTick = false;

    // 스태틱 메시 컴포넌트를 생성
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    // 메시 컴포넌트를 이 액터의 루트로 설정
    RootComponent = MeshComponent;

    // 생성자에서만 사용 가능한 FObjectFinder를 통해 기본 메시 애셋을 탐색
    static ConstructorHelpers::FObjectFinder<UStaticMesh> SphereMeshAsset(TEXT("/Engine/BasicShapes/Sphere.Sphere"));

    // 안전장치 : 애셋을 성공적으로 찾았는지 확인
    if (SphereMeshAsset.Succeeded())
    {
        // 찾은 구체 메시를 MeshComponent에 적용
        MeshComponent->SetStaticMesh(SphereMeshAsset.Object);
        // 기본 스케일을 조절
        MeshComponent->SetRelativeScale3D(FVector(1.0f, 1.0f, 1.0f));
    }
}

// 게임이 시작될 때 첫 번째 타이머를 설정하여 로직을 시작
void ATimedPlatform::BeginPlay()
{
    Super::BeginPlay();

    // 월드의 타이머 관리자를 통해 타이머를 설정
    GetWorld()->GetTimerManager().SetTimer(
        DisappearTimerHandle,       // 제어할 타이머 핸들
        this,                       // 함수를 호출할 대상 객체
        &ATimedPlatform::Disappear, // 호출될 함수
        DisappearDelay,             // 지연 시간
        false                       // 반복 여부 (false: 한 번만 실행)
    );
}

// 플랫폼을 사라지게 하는 타이머를 설정, Delay 이후 Reappear 호출(순환구조)
void ATimedPlatform::Disappear()
{
    // 플랫폼을 보이지 않고 충돌하지 않는 상태로 변경
    SetPlatformState(false);

    // ReappearDelay초 후에 Reappear 함수를 호출하도록 타이머를 설정
    GetWorld()->GetTimerManager().SetTimer(
        ReappearTimerHandle,
        this,
        &ATimedPlatform::Reappear,
        ReappearDelay,
        false
    );
}

// 플랫폼을 나타나게 하는 타이머를 설정, Delay 이후 Disappear 호출(순환구조)
void ATimedPlatform::Reappear()
{
    // 플랫폼을 보이고 충돌 가능한 상태로 변경
    SetPlatformState(true);

    // DisappearDelay초 후에 Disappear 함수를 호출하도록 타이머를 설정
    GetWorld()->GetTimerManager().SetTimer(
        DisappearTimerHandle,
        this,
        &ATimedPlatform::Disappear,
        DisappearDelay,
        false
    );
}

// 플랫폼의 가시성과 충돌 설정
void ATimedPlatform::SetPlatformState(bool bIsVisible)
{
    // 액터를 게임 월드에서 시각적으로 숨기거나 보이게
    SetActorHiddenInGame(!bIsVisible);
    // 액터의 충돌 기능을 활성화 또는 비활성화
    SetActorEnableCollision(bIsVisible);
}

PuzzleSpawner.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PuzzleSpawner.generated.h"

class UBoxComponent;
class AMovingPlatform;
class ARotatingPlatform;

// 스폰할 플랫폼의 종류별 설정을 묶어서 관리하기 위한 구조체
USTRUCT(BlueprintType)
struct FSpawnablePlatformInfo
{
    GENERATED_BODY()

    // 스폰할 플랫폼의 C++ 또는 블루프린트 클래스
    UPROPERTY(EditAnywhere)
    TSubclassOf<AActor> PlatformClass;

    // 해당 종류의 플랫폼을 몇 개 스폰할지 결정하는 개수
    UPROPERTY(EditAnywhere, meta = (ClampMin = "0"))
    int32 SpawnCount = 5;

    // 스폰될 때 적용될 최소 크기
    UPROPERTY(EditAnywhere)
    FVector MinScale = FVector(1.0, 1.0, 0.1f);

    // 스폰될 때 적용될 최대 크기
    UPROPERTY(EditAnywhere)
    FVector MaxScale = FVector(3.0f, 3.0f, 0.3f);
};


UCLASS()
class HOMEWORK6_API APuzzleSpawner : public AActor
{
    GENERATED_BODY()

public:
    APuzzleSpawner();

protected:
    virtual void BeginPlay() override;

private:
    // 플랫폼이 스폰될 영역을 시각적으로 나타내는 컴포넌트
    UPROPERTY(VisibleAnywhere, Category = "Spawner Settings")
    UBoxComponent* SpawnVolume;

    // 에디터에서 설정할 스폰 대상 플랫폼들의 정보 배열
    UPROPERTY(EditAnywhere, Category = "Spawner Settings")
    TArray<FSpawnablePlatformInfo> PlatformsToSpawn;

    // MovingPlatform의 이동 속도 랜덤 범위 (X: 최소, Y: 최대)
    UPROPERTY(EditAnywhere, Category = "Randomization | Moving Platform")
    FVector2D MoveSpeedRange = FVector2D(50.f, 250.f);

    // MovingPlatform의 이동 거리 랜덤 범위 (X: 최소, Y: 최대)
    UPROPERTY(EditAnywhere, Category = "Randomization | Moving Platform")
    FVector2D MaxRangeRange = FVector2D(300.f, 1000.f);

    // RotatingPlatform의 회전 속도 최소값
    UPROPERTY(EditAnywhere, Category = "Randomization | Rotating Platform")
    float MinRotationSpeed = 10.f;

    // RotatingPlatform의 회전 속도 최대값
    UPROPERTY(EditAnywhere, Category = "Randomization | Rotating Platform")
    float MaxRotationSpeed = 100.f;

    // TimedPlatform이 사라지기까지의 시간 랜덤 범위 (X: 최소, Y: 최대)
    UPROPERTY(EditAnywhere, Category = "Randomization | Timed Platform")
    FVector2D DisappearDelayRange = FVector2D(2.0f, 5.0f);

    // TimedPlatform이 다시 나타나기까지의 시간 랜덤 범위 (X: 최소, Y: 최대)
    UPROPERTY(EditAnywhere, Category = "Randomization | Timed Platform")
    FVector2D ReappearDelayRange = FVector2D(1.0f, 3.0f);

private:
    // 모든 플랫폼의 스폰을 처리하는 메인 함수
    void SpawnAllPlatforms();
};

PuzzleSpawner.cpp

#include "PuzzleSpawner.h"
#include "Components/BoxComponent.h"
#include "Kismet/KismetMathLibrary.h"
#include "MovingPlatform.h"
#include "RotatingPlatform.h"
#include "TimedPlatform.h"

APuzzleSpawner::APuzzleSpawner()
{
    // 매 프레임 업데이트가 필요 없으므로 Tick을 비활성화
    PrimaryActorTick.bCanEverTick = false;
    // 스폰 영역을 담당할 박스 컴포넌트를 생성하고 루트로 설정
    SpawnVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawnVolume"));
    RootComponent = SpawnVolume;
}

// 액터 생성시 모든 플랫폼을 스폰하는 함수를 호출
void APuzzleSpawner::BeginPlay()
{
    Super::BeginPlay();
    SpawnAllPlatforms();
}

void APuzzleSpawner::SpawnAllPlatforms()
{
    FActorSpawnParameters SpawnParams;
    // 스폰 시 다른 액터와 충돌하더라도 위치를 조정해서 항상 스폰되도록 설정
    SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;

    // 에디터에 설정된 플랫폼 정보 배열을 하나씩 순회
    for (const FSpawnablePlatformInfo& PlatformInfo : PlatformsToSpawn)
    {
        // 안전장치 : 스폰할 클래스가 지정되지 않았거나 스폰 개수가 0 이하면 넘어감
        if (PlatformInfo.PlatformClass == nullptr || PlatformInfo.SpawnCount <= 0)
        {
            continue;
        }

        // 해당 종류의 플랫폼을 지정된 SpawnCount만큼 반복하여 생성
        for (int32 i = 0; i < PlatformInfo.SpawnCount; ++i)
        {
            // SpawnVolume 영역 내에서 랜덤한 위치를 계산합니다.
            FVector SpawnLocation = UKismetMathLibrary::RandomPointInBoundingBox(
                SpawnVolume->GetComponentLocation(), // 박스의 중심 위치
                SpawnVolume->GetScaledBoxExtent()    // 박스의 절반 크기 (범위)
            );

            // 계산된 위치에 액터를 스폰
            AActor* SpawnedActor = GetWorld()->SpawnActor<AActor>(
                PlatformInfo.PlatformClass,
                SpawnLocation,
                FRotator::ZeroRotator,
                SpawnParams
            );

            // 안전장치 : 액터가 성공적으로 스폰되었는지 확인
            if (SpawnedActor)
            {
                // 설정된 최소/최대 범위 내에서 랜덤한 스케일(크기) 값을 계산
                float RandX = FMath::RandRange(PlatformInfo.MinScale.X, PlatformInfo.MaxScale.X);
                float RandY = FMath::RandRange(PlatformInfo.MinScale.Y, PlatformInfo.MaxScale.Y);
                float RandZ = FMath::RandRange(PlatformInfo.MinScale.Z, PlatformInfo.MaxScale.Z);
                // 계산된 랜덤 스케일을 스폰된 액터에 적용
                SpawnedActor->SetActorScale3D(FVector(RandX, RandY, RandZ));

                // MovingPlatform 클래스 액터에 속성 설정
                if (AMovingPlatform* MovingPlatform = Cast<AMovingPlatform>(SpawnedActor))
                {
                    MovingPlatform->MoveSpeed = FMath::RandRange(MoveSpeedRange.X, MoveSpeedRange.Y);
                    MovingPlatform->MaxRange = FMath::RandRange(MaxRangeRange.X, MaxRangeRange.Y);
                }
                // RotatingPlatform 클래스 액터에 속성 설정
                else if (ARotatingPlatform* RotatingPlatform = Cast<ARotatingPlatform>(SpawnedActor))
                {
                    RotatingPlatform->RotationSpeed = FMath::RandRange(MinRotationSpeed, MaxRotationSpeed);
                }
                // TimedPlatform 클래스 액터에 속성 설정
                else if (ATimedPlatform* TimedPlatform = Cast<ATimedPlatform>(SpawnedActor))
                {
                    TimedPlatform->DisappearDelay = FMath::RandRange(DisappearDelayRange.X, DisappearDelayRange.Y);
                    TimedPlatform->ReappearDelay = FMath::RandRange(ReappearDelayRange.X, ReappearDelayRange.Y);
                }
            }
        }
    }
}

⚠️ 실수

  • 스폰된 액터가 보이지 않는 문제: PuzzleSpawner는 정상 동작하는 것 같은데 아무것도 보이지 않았다. 원인은 스폰 대상인 MovingPlatform 같은 클래스에 기본 스태틱 메시가 설정되어 있지 않았기 때문이었다. C++ 생성자에서 ConstructorHelpers::FObjectFinder를 사용해 기본 메시를 지정해주니 해결되었다. C++ 클래스는 기능의 뼈대일 뿐, 외형은 별도로 지정해야 한다는 것을 깨달았다.
  • 코드를 수정했더니 아무것도 스폰되지 않는 문제: PuzzleSpawner의 변수 구조를 단일 변수에서 TArray<FSpawnablePlatformInfo> 배열로 변경했더니 갑자기 스폰이 멈췄다. 확인해보니 에디터에 설정해 둔 스포너의 배열 크기가 0으로 초기화되어 있었다. C++ 코드의 구조가 크게 바뀌면 에디터에 저장된 값이 유실될 수 있다는 점을 배웠다. 이럴 땐 당황하지 말고 에디터의 Details 패널 설정을 다시 확인해야 한다.

✅ 핵심 요약

개념설명비고
UPROPERTYC++ 변수를 언리얼 에디터의 Details 패널에 노출시키는 매크로.EditAnywhere, VisibleAnywhere 등 지정자로 세부 제어 가능.
Tick(float DeltaTime)매 프레임 호출되는 함수로, DeltaTime을 곱해 프레임 독립성을 확보한다.지속적인 움직임, 회전 등 연속적인 로직에 사용.
GetWorld()->GetTimerManager()Tick보다 효율적인 시간 기반 이벤트 처리 시스템.SetTimer 함수로 특정 시간 후 또는 주기적인 함수 호출을 예약한다.
Cast<T>()부모 클래스 포인터를 자식 클래스 타입으로 안전하게 형 변환한다.스폰된 AActor의 실제 타입을 확인하고 고유 변수에 접근할 때 필수.
TSubclassOf<T>특정 클래스 및 그 자식 클래스만 에디터 드롭다운에서 선택하게 하는 타입.스포너에서 스폰할 액터의 종류를 유연하게 지정할 때 매우 유용하다.

profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글