언리얼엔진 학습 및 과제

팀 프로젝트 슈터 게임 (6)

park94128 2025. 11. 11. 23:00

● 잠시 다른 팀원을 역할을 빌리다

아이템을 담당하는 팀원에게 아이템 제작을 의뢰했지만 매우 바쁜 상황이었다. 남은 시간도 별로 없는 상황이다. 그래서 양해를 구하고 내가 필요한 아이템을 직접 만들기로 했다.

 

A. 경험치 증가 아이템

UCLASS()
class TEAM14CH3PROJECT_API AExpItem : public ABaseItem
{
	GENERATED_BODY()
	
public:	
	AExpItem();

	//이 아이템이 제공하는 경험치량
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Item")
	int32 ExpAmount;

protected:
	virtual void ActivateItem(AActor* Activator) override;

	//블루프린트로 구현될 경험치 증가 함수
	UFUNCTION(BlueprintImplementableEvent, Category = "Item")
	void AddExp();
};

팀원이 만든 다른 아이템을 참고하여 클래스를 만들었다. 기본 아이템 클래스를 상속받아 필요한 함수를 선언했다. 다른 팀원의 내용을 참고해보니 경험치 증가하는 기능은 블루프린트로 구현했길래 나도 경험치 증가 부분은 블루프린트로 연동하기로 했다. 그러기 위해 BlueprintImplementableEvent가 적힌 UFUNCTION 함수를 선언했다.

 

void AExpItem::ActivateItem(AActor* Activator)
{
	AddExp();
	DestroyItem();
}

구현을 블루프린트에서 한다고 해도 실행은 코드에서 해야하니 아이템 기능을 실행하는 함수에 그 함수를 호출하도록 했다.

 

팀원이 만든 기본 아이템 클래스를 상속받고 만든 클래스로 블루프린트를 생성하면 스태틱 메시와 콜리전이 생성된다. 사용하지 않은 메시 중 적절한 것이 뭐가 있나 찾아봤는데 온전한 음료수 캔 메시를 사용한 곳이 없었다. 그래서 이 메시가 경험치 아이템의 모습이 됐다.

 

A - 1. C++코드에서 선언한 함수를 블루프린트에서 구현하기

//블루프린트로 구현될 경험치 증가 함수
UFUNCTION(BlueprintImplementableEvent, Category = "Item")
void AddExp();

상술했듯 BlueprintImplementableEvent가 적힌 UFUNCTION 함수가 필요한데, 이 키워드를 적은 함수는 C++ 코드에서 내용 선언을 하면 안된다. 여기에 더하여 virtual 선언도 허용하지 않는다.

 

이렇게 선언한 함수는 블루프린트의 이벤트그래프에서 가져올 수 있다. 여기서 이 함수(블루프린트 에서는 이벤트)의 내용을 구현하여 기능을 완성했다.

 

B. 카드 아이템

카드 아이템은 게임 진행용 아이템이다. 스테이지 2에 있는 문 스위치 액터 중 특정 조건 하에 작동하는 스위치가 있도록 만들 생각이다. 그래서 이 아이템을 제작했다.

//CardItem.h
UCLASS()
class TEAM14CH3PROJECT_API ACardItem : public ABaseItem
{
	GENERATED_BODY()
	
public:	
	ACardItem();

protected:
	virtual void BeginPlay() override;
	
public:
	virtual void Tick(float DeltaTime) override;

protected:
	virtual void ActivateItem(AActor* Activator) override;

private:
	//회전 속도
	UPROPERTY(EditAnywhere, Category = "Item", meta = (AllowPrivateAccess = true))
	float RotationSpeed;

	//회전 속도에 기반한 회전 값
	FRotator RotatorValue;
};


//CardItem.cpp
ACardItem::ACardItem()
{
	PrimaryActorTick.bCanEverTick = true;

	ItemType = "Card";
	RotationSpeed = 0;
	RotatorValue = FRotator::ZeroRotator;
}

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

	RotatorValue = FRotator(0, RotationSpeed, 0);
}

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

	AddActorWorldRotation(RotatorValue * DeltaTime);
}

void ACardItem::ActivateItem(AActor* Activator)
{
	//카드 획득 표시
	UCH3GameInstance* GameInstance = Cast<UCH3GameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	GameInstance->Level2KeyCard = true;
	
	DestroyItem();
}

 

나머지는 별거 없지만, 그냥 가만히 있으면 별로일 것 같아서 제자리에서 회전하도록 만들었다.

 

이 아이템의 획득 여부를 기록하기 위한 변수가 필요한데, 게임 인스턴스를 담당한 팀원에게 부탁하여 거기에 기록하도록 했다.

블루프린트를 생성하고 카드 메시를 넣고 완성했는데, 스테이지2가 워낙 어둡다보니 눈에 잘 안띌것 같아서 광원을 배치했다. 이왕이면 눈에 잘 띄라고 광원 색깔을 초록색으로 만들었다.

● 레벨 기믹 제작

A. 방별 무작위 액터 배치

스테이지1에는 수많은 방이 있다. 그리고 플레이어가 시작하는 곳을 제외한 나머지 방에는 무엇이 있는지 알 수 없는 것이 특징이다.

 

원래는 일부 방은 열려 있거니 비어 있을 수도 있는데 시간 문제로 그냥 모든 방 문이 닫혀 있고 각 방에는 반드시 적이나 아이템이 있도록 만들었다.

 

UCLASS()
class ARoomActorShuffler : public AActor
{
	GENERATED_BODY()
	
public:	
	ARoomActorShuffler();

protected:
	virtual void BeginPlay() override;

private:
	//지정한 TArray내 모든 요소의 순서 변경
	template<typename T>
	void ShuffleTArray(TArray<T>& Array);

	//생성할 적 개수
	UPROPERTY(EditInstanceOnly, Category = "Spawn Actor", meta = (AllowPrivateAccess = true))
	int32 SpawnEnemyCount;
	
	//생성할 적 액터 목록
	UPROPERTY(EditInstanceOnly, Category = "Spawn Actor", meta = (AllowPrivateAccess = true))
	TArray<TSubclassOf<AActor>> SpawnEnemyActors;

	//생성할 아이템 개수
	UPROPERTY(EditInstanceOnly, Category = "Spawn Actor", meta = (AllowPrivateAccess = true))
	int32 SpawnItemCount;

	//생성할 아이템 액터 목록
	UPROPERTY(EditInstanceOnly, Category = "Spawn Actor", meta = (AllowPrivateAccess = true))
	TArray<TSubclassOf<AActor>> SpawnItemActors;

	//생성 위치 목록
	UPROPERTY(EditInstanceOnly, Category = "Spawn Actor", meta = (AllowPrivateAccess = true))
	TArray<FVector> SpawnLocations;
};

각 방마다 생성할 아이템 또는 적 액터를 TArray로 선언하여 여러개 받도록 했다. 그리고 생성할 적과 아이템 개수를 지정한다. 마지막으로 생성 위치 또한 TArray로 받는다.

 

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

	//빈 배열 불허
	if (SpawnEnemyActors.Num() == 0 || SpawnItemActors.Num() == 0 || SpawnLocations.Num() == 0)
	{
		UE_LOG(LogProcess, Warning, TEXT("RoomActorShuffler에 소환할 액터와 위치를 지정해주세요"));
		return;
	}

	//생성 위치 섞기
	ShuffleTArray(SpawnLocations);

	//각 생성 위치에 적 또는 아이템 생성
	int32 AllSpawnCount = SpawnEnemyCount + SpawnItemCount;
	for (int32 iNum = 0; iNum < AllSpawnCount; ++iNum)
	{
		TArray<TSubclassOf<AActor>> SelectedArray = iNum < SpawnEnemyCount ? SpawnEnemyActors : SpawnItemActors;
		TSubclassOf<AActor> SpawnActor = SelectedArray[FMath::RandRange(0, SelectedArray.Num() - 1)];

		if (!SpawnLocations.IsValidIndex(iNum))
		{
			break;
		}

		GetWorld()->SpawnActor<AActor>(SpawnActor, SpawnLocations[iNum], FRotator::ZeroRotator);
	}
}

게임 시작시 아래 순서대로 진행된다.

- 각 TArray마다 비었는지 확인한다. 비어있지 않아야 한다.

- 아이템 생성 위치를 담은 TArray내 각 원소를 섞는다.

- 섞은 생성 위치의 순서에 따라 우선 적 액터를 생성한다.

- 그 다음 아이템 액터를 생성한다.

 

template <typename T>
void ARoomActorShuffler::ShuffleTArray(TArray<T>& Array)
{
	//섞기
	const int32 Length = Array.Num();
	for (int32 iNum = Length - 1; iNum > 0 ; --iNum)
	{
		//0부터 iNum까지 무작위 위치를 고르고 현재 위치와 교환 
		if (int32 RandomIndex = FMath::RandRange(0, iNum); iNum != RandomIndex)
		{
			Array.Swap(iNum, RandomIndex);
		}
	}
}

TArray내 원소 순서를 무작위로 바꾸는 함수는 구글링을 통해 발견된 방법을 토대로 구현했다.

 

블루프린트를 거칠 것도 없이 바로 맵에 배치하고 값을 할당했다. 아이템은 종류가 많을 거라고 생각했지만 결국 경험치 아이템 1개만 집어넣었다. 적 액터는 병원에 어울릴만한 것만 지정했다.

 

B. 퍼즐

스테이지3은 퍼즐이 메인 기믹이다. 그리고 그 퍼즐은 매번 정답이 다르다. 그 내용에 따라 힌트의 내용이 달라지도록 만들어야 한다.

 

UCLASS()
class AStage3Puzzle : public AActor
{
    GENERATED_BODY()
    
private:
    static AStage3Puzzle* Instance;
public:
    static AStage3Puzzle* GetInstance();
    
    AStage3Puzzle();

protected:
    virtual void PostInitializeComponents() override;
    virtual void BeginPlay() override;

public:
    virtual void BeginDestroy() override;
    
    //퍼즐 정답
    FString GetPuzzleAnswer();

private:
    //정답 생성 및 정답에 따라 텍스트 렌더에 반영
    void MakingAnswer();
    
    //퍼즐 정답
    UPROPERTY(VisibleInstanceOnly, Category = "Puzzle", meta = (AllowPrivateAccess = true))
    FString PuzzleAnswer;

    //A를 보여줄 텍스트
    UPROPERTY(EditInstanceOnly, Category = "Puzzle", meta = (AllowPrivateAccess = true))
    class ATextRenderActor* HintAText = nullptr;

    //B를 보여줄 텍스트
    UPROPERTY(EditInstanceOnly, Category = "Puzzle", meta = (AllowPrivateAccess = true))
    ATextRenderActor* HintBText = nullptr;

    //C를 보여줄 주시위 액터
    UPROPERTY(EditInstanceOnly, Category = "Puzzle", meta = (AllowPrivateAccess = true))
    AActor* HintCDice = nullptr;

    //1월부터 9월까지 영문 이름
    const TArray<FString> MonthNames = { TEXT("January"), TEXT("February"), TEXT("March"),
        TEXT("April"), TEXT("May"), TEXT("June"), TEXT("July"), TEXT("August"), TEXT("September") };

    //힌트 C인 주사위의 회전값
    const TArray<FRotator> DiceRotationValues = { FRotator(90, 0, 0), FRotator(0, 0, -90),
       FRotator(180, 0, 0), FRotator(), FRotator(0, 0, 90), FRotator(-90, 0, 0) };
};

퍼즐 정답은 FString값으로 저장된다. 그리고 이 퍼즐 정답을 외부에서 볼 수 있도록 액터를 static instance로 만들었다.

 

1번째, 2번째 자리는 텍스트 렌더 액터로 보여주고 3번째 자리는 주사위 액터를 통해 보여준다.

1번째 자리는 월(月)을 영어로 보여줄 것이니 미리 각 이름을 저장한다. (10월 이상은 사용 불가)

3번째 자리인 주사위는 각 숫자에 대응되는 회전값을 찾아내 미리 저장한다.

4번째 자리는 앞 숫자중 제일 큰 값으로 지정하기만 하면 되니 이 액터에서는 힌트를 보여줄 수단이 필요없다.

 

void AStage3Puzzle::MakingAnswer()
{
	//A : 1 ~ 9 중 하나
	const int32 HintANum = FMath::RandRange(1, 9);
	//"월 일"조합의 텍스트를 출력, 일은 1 이상 27 이하인 숫자
	const FString HintAString = MonthNames[HintANum - 1] + TEXT(" ") + FString::FromInt(FMath::RandRange(1, 27));
	HintAText->GetTextRender()->SetText(FText::FromString(HintAString));

	//B : 0 ~ 9 중 하나
	const int32 HintBNum = FMath::RandRange(0, 9);
	//"B = 숫자"조합의 텍스트를 출력
	HintBText->GetTextRender()->SetText(FText::FromString(FString::Printf(TEXT("B = %d"), HintBNum)));

	//C : 1 ~ 6 중 하나
	const int32 HintCNum = FMath::RandRange(1, 6);
	//숫자와 일치하도록 주사위 액터를 회전
	HintCDice->SetActorRotation(DiceRotationValues[HintCNum - 1]);

	//D : A, B, C 중 제일 큰 숫자와 같음
	const int32 HintDNum = FMath::Max3(HintANum, HintBNum, HintCNum);
	
	//정답
	PuzzleAnswer = FString::Printf(TEXT("%d%d%d%d"), HintANum, HintBNum, HintCNum, HintDNum);
}

1번째 자리는 월에 대응되도록 1 ~ 9의 값을 정한다. 그리고 "월 일"형식으로 적기 위해 일에 대응되는 숫자도 생성한다. 이때 2월은 28일 까지 있고 "내일이 생일"이라고 적혀있으니 1 ~ 27의 값으로 한다.

2번째 자리는 그냥 임의의 한자리 숫자라서 0 ~ 9의 값을 정한다. 출력 형식도 그냥 "B = %d"다.

3번째 자리는 주사위에 대응되도록 1 ~ 6의 값을 정한다. 그리고 이 숫자에 맞게 주사위를 돌린다.

4번째 자리는 위 3개중 큰 값과 동일하다.

이 4개의 숫자 조합을 FString으로 변환하여 저장한다.

 

1번째 자리에 대한 힌트

맵에 배치하여 적절한 액터를 할당했다. 그러고 나서 매 실행마다 힌트가 바뀌는 모습을 확인했다.

 

C. 다음 스테이지로 이동

어느 한 스테이지의 고유 기믹은 아니지만 1, 2 스테이지에서 다음 스테이지로 넘어가기 위해 필수로 존재하는 기능을 가진 액터다.

 

UCLASS()
class ANextStageTrigger : public AActor
{
	GENERATED_BODY()
	
public:	
	ANextStageTrigger();

protected:
	virtual void PostInitializeComponents() override;

private:
	//캐릭터와 겹칠 때 실행
	UFUNCTION()
	void OnPlayerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

	//플레이어와 겹칠 범위인 콜라이더
	UPROPERTY(EditAnywhere, Category = "Next Stage Setting", meta = (AllowPrivateAccess = true))
	class UCapsuleComponent* Capsule;
};

코드 자체는 대단한 것이 아니다. 그냥 지정한 콜라이더와 캐릭터가 겹치면 다음 스테이지로 이동하는 기능을 실행하면 그만이다.

 

void ANextStageTrigger::OnPlayerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	//플레이어와 겹친 경우에만 실행
	if (ABaseCharacter* PlayerChar = Cast<ABaseCharacter>(OtherActor); !::IsValid(PlayerChar))
	{
		return;
	}

	//다음 스테이지로
	if (ACH3GameState* Ch3GameState = GetWorld()->GetAuthGameMode()->GetGameState<ACH3GameState>())
	{
		Ch3GameState->EndLevel();
	}
}

겹친 액터가 플레이어 캐릭터인지 확인을 한 다음 플레이어가 맞다면 다음 스테이지로 이동하는 기능을 실행한다. 스테이지 이동 기능은 게임 스테이트를 만든 팀원이 구현해줬기에 손쉽게 구현할 수 있었다.

 

콜라이더의 크기 및 콜리전 프리셋은 블루프린트로 설정한다.

 

스테이지 1은 복도 끝의 문 앞에, 스테이지 2는 엘리베이터 안쪽에 배치했다. 스테이지 1은 복도 끝으로 가기만 하면 되지만 스테이지 2는 엘리베이터 문이 열려야 갈 수 있다.

 

스테이지 3은 마지막 스테이지라서 배치하지 않았다.