오늘은 언리얼 엔진에서 C++를 사용하여 동적인 퍼즐 스테이지를 만드는 과제를 진행했다. 움직이는 발판, 회전하는 발판, 그리고 나타났다 사라지는 발판을 각각의 C++ Actor 클래스로 구현했다. 또한, 이들을 게임 시작 시점에 랜덤한 위치와 속성으로 자동 생성하는 Spawner를 만들어 절차적 레벨 생성의 기초를 다졌다. 이 과정을 통해 UPROPERTY로 C++ 코드를 에디터와 연결하고, Tick과 Timer를 활용한 동적 로직 구현, Cast를 이용한 객체 타입 확인 등 핵심적인 개념들을 체득할 수 있었다. 🚀
AActor 클래스를 상속받아 여러 종류의 게임 오브젝트 만들기Tick 함수와 DeltaTime을 이용한 프레임 독립적인 움직임 구현하기FTimerManager를 활용한 시간 기반의 이벤트 처리 로직 구현하기UPROPERTY 매크로를 사용해 C++ 변수를 언리얼 에디터에서 제어하기SpawnActor와 Cast를 활용하여 동적으로 액터를 생성하고 속성을 제어하는 Spawner 만들기AActor는 언리얼 월드에 배치될 수 있는 모든 오브젝트의 기본 클래스이다. 우리가 레벨에 놓는 의자, 캐릭터, 발판 모두 액터에 해당한다. C++에서 AActor를 상속받아 우리만의 기능을 가진 클래스를 만들 수 있다.
UPROPERTY는 C++ 클래스의 멤버 변수를 언리얼 엔진의 리플렉션 시스템에 등록하는 매크로이다. 이 매크로 덕분에 C++ 코드에 선언한 변수를 에디터의 Details 패널에서 직접 보고 수정할 수 있게 된다. EditAnywhere, VisibleAnywhere 같은 지정자를 통해 노출 방식과 권한을 세밀하게 제어할 수 있다.
Tick(float DeltaTime) 함수는 매 프레임마다 호출되는 특별한 함수이다. 주로 연속적인 움직임이나 상태 변화를 구현할 때 사용한다. 여기서 DeltaTime은 이전 프레임과 현재 프레임 사이의 시간 간격으로, 이를 곱해주어야 컴퓨터 성능(프레임 속도)에 관계없이 일정한 속도로 움직이는 프레임 독립적인 로직을 만들 수 있다.
GetWorld()->GetTimerManager()는 Tick보다 훨씬 효율적으로 시간 기반 이벤트를 처리하는 시스템이다. "N초 후에 이 함수를 한 번 실행해줘" 또는 "N초마다 이 함수를 반복 실행해줘" 와 같은 예약 작업을 걸 수 있다. 매 프레임 체크할 필요가 없는 로직에 사용하면 성능을 크게 아낄 수 있다.
#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);
};
#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);
}
#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();
};
#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 패널 설정을 다시 확인해야 한다.| 개념 | 설명 | 비고 |
|---|---|---|
UPROPERTY | C++ 변수를 언리얼 에디터의 Details 패널에 노출시키는 매크로. | EditAnywhere, VisibleAnywhere 등 지정자로 세부 제어 가능. |
Tick(float DeltaTime) | 매 프레임 호출되는 함수로, DeltaTime을 곱해 프레임 독립성을 확보한다. | 지속적인 움직임, 회전 등 연속적인 로직에 사용. |
GetWorld()->GetTimerManager() | Tick보다 효율적인 시간 기반 이벤트 처리 시스템. | SetTimer 함수로 특정 시간 후 또는 주기적인 함수 호출을 예약한다. |
Cast<T>() | 부모 클래스 포인터를 자식 클래스 타입으로 안전하게 형 변환한다. | 스폰된 AActor의 실제 타입을 확인하고 고유 변수에 접근할 때 필수. |
TSubclassOf<T> | 특정 클래스 및 그 자식 클래스만 에디터 드롭다운에서 선택하게 하는 타입. | 스포너에서 스폰할 액터의 종류를 유연하게 지정할 때 매우 유용하다. |
