언리얼 엔진/프로젝트

[언리얼 엔진] 팀 프로젝트 제작 일기 03 - 변경된 방 구조에 맞춰 Room Flow 수정하기

dhlee-dev 2026. 5. 13. 22:41

이번에는 팀 프로젝트의 방 구조 기획이 변경되면서, 기존에 구현해두었던 Room Flow를 수정한 내용을 정리해보려고 한다.

기존에는 SafeRoom을 시작 지점처럼 사용하고, 각 방이 NextRooms 배열을 통해 다음 방과 연결되는 구조였다.
다시 말해, 방을 클리어하고 연결된 다음 방으로 진행하는 방식이었다.

하지만 기획이 변경되면서 게임 구조가 StartRoom을 중심으로 바뀌었다.

이제는 한 층 안에 StartRoom이 있고, 그 주변에 CombatRoom, RandomRoom이 배치된다.
플레이어는 StartRoom에서 출발해 주변 방들을 클리어하고, CombatRoom 클리어 횟수 조건을 만족하면
다시 StartRoom에 생성된 포탈을 통해 다음 층으로 이동한다.


변경된 방 구조

기존 구조는 시작 지점에서 계속 다음 방으로 이어지는 방식에 가까웠다.

하지만 변경된 구조는 StartRoom을 중심으로 여러 방이 배치되는 방식이다.

1층과 2층에서는 StartRoom 주변에 CombatRoom과 RandomRoom이 배치되고,
CombatRoom을 모두 클리어하면 StartRoom에 다음 층으로 이동하는 포탈이 활성화된다.

3층에서는 StartRoom에서 바로 BossRoom으로 이동할 수 있는 구조로 생각했다.

이 변경으로 인해 기존의 NextRooms 기반 구조는 더 이상 맞지 않게 되었다.


기존 구조의 문제

처음 구현했던 Room 구조에서는 RoomBase가 다음 방 정보를 가지고 있었다.

TArray<TObjectPtr<ARoomBase>> NextRooms;

그리고 방을 클리어하면 다음 방을 열어주는 식으로 흐름을 관리했다.

하지만 변경된 기획에서는 특정 방이 다음 방을 직접 가리키는 구조가 아니다.

방들이 StartRoom 주변에 배치되어 있고, 플레이어가 어느 방향으로 이동하느냐에 따라 CombatRoom이나 RandomRoom에 진입한다.

따라서 “이 방을 클리어하면 다음 방을 연다”보다는, “전투방을 몇 개 클리어했는지 확인하고 조건을 만족하면 StartRoom의 포탈을 활성화한다”는 구조가 더 적절하다고 판단했다.

그래서 NextRooms 기반 진행을 제거하고, CombatRoom 클리어 개수를 기준으로 포탈을 활성화하는 방식으로 변경했다.


RoomType과 RoomState 정리

먼저 방 타입을 현재 기획에 맞게 다시 정리했다.

새로운 구조에서는 Start, Combat, Random, Boss가 중심이 된다.

Safe는 이전 구조에서 사용하던 타입이라 현재 기획에서는 중심적으로 사용하지 않지만,
기존 코드를 바로 제거하지 않고 일단 남겨두었다.

그리고 방의 상태에 Prepared를 추가했다.

Waiting
- 아직 시작되지 않은 방

Prepared
- 전투 준비 단계에 들어간 방
- 통로 중간에 진입해서 이전 방으로 돌아가는 문이 닫히고 몬스터가 스폰된 상태

InProgress
- 플레이어가 방에 완전히 진입해서 전투가 진행 중인 상태

Cleared
- 방 클리어가 완료된 상태

이번 기획에서는 플레이어가 통로를 이동하다가 중간쯤 지나면 이전 방으로 돌아가는 문이 닫히고, 몬스터가 스폰된다.
방에 완전히 들어오면 방 입구 쪽 문이 닫힌다.
그래서 단순히 방 시작과 종료만으로는 부족했고, “방에 들어가기 전 준비 단계”가 필요했다.


RoomBase 수정

RoomBase는 모든 방의 공통 부모 클래스이기 때문에, 변경된 방 구조에 맞춰 가장 먼저 수정했다.
기존에는 다음 방 연결과 잠금 상태를 관리했다면, 이제는 방 상태와 문 제어를 중심으로 변경했다.

EntranceDoors는 플레이어가 방에 완전히 진입했을 때 닫히는 문들이다.

방이 시작되면 연결된 문을 닫고, 방이 종료되면 다시 열도록 했다.

void ARoomBase::StartRoom(
    AGunFireGameMode* GFGameMode,
    AGunFireGameState* GFGameState
)
{
    if (!GFGameMode || !GFGameState)
    {
        UE_LOG(LogTemp, Warning, TEXT("Game Mode or Game State Null"));
        return;
    }

    for (const auto& Door : EntranceDoors)
    {
        if (IsValid(Door))
        {
            Door->CloseDoor();
        }
    }

    RoomState = ERoomState::InProgress;
    OnStart(GFGameMode, GFGameState);
}

방이 종료될 때는 반대로 문을 연다.

void ARoomBase::EndRoom(
    AGunFireGameMode* GFGameMode,
    AGunFireGameState* GFGameState
)
{
    if (!GFGameMode || !GFGameState)
    {
        UE_LOG(LogTemp, Warning, TEXT("Game Mode or Game State Null"));
        return;
    }

    for (const auto& Door : EntranceDoors)
    {
        if (IsValid(Door))
        {
            Door->OpenDoor();
        }
    }

    RoomState = ERoomState::Cleared;
    OnEnd(GFGameMode, GFGameState);
}

이렇게 하면 각 방은 자신과 연결된 문만 알고 있으면 되고, 실제 문이 열리고 닫히는 동작은 Door 쪽에서 처리할 수 있다.


DoorBase 추가

방 구조가 바뀌면서 문 제어가 필요해졌다.
기존에는 방 잠금 여부를 bool 값으로 판단했다면, 이제는 실제 문이나 게이트로 플레이어의 이동을 막아야 한다.
그래서 DoorBase 클래스를 추가했다.

OpenDoor, CloseDoor는 C++에서 호출할 수 있고, 필요하면 블루프린트에서 구현을 오버라이드할 수 있는 함수이다.
실제 애니메이션이나 메시 이동, 사운드 재생 같은 연출은 블루프린트에서 구현이 편하기 때문에 BlueprintNativeEvent를 사용했다.


통로 구조와 Prepare 단계

이번 기획에서 중요한 부분 중 하나는 통로 구조이다.

방과 방 사이에는 통로가 있고, 통로에는 양쪽 끝에 문이 있다.

플레이어가 통로 중간쯤 지나면 이전 방으로 돌아가는 문이 닫혀서 되돌아갈 수 없게 된다.

그리고 방에 완전히 진입하면 방과 가까운 문도 닫히고 전투가 시작된다.

이 구조를 구현하기 위해 CombatRoomPrepareTrigger를 추가했다.

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Room|Component")
TObjectPtr<UBoxComponent> PrepareTrigger;

PrepareTrigger는 플레이어가 통로 중간 지점에 들어왔을 때 발동된다.

이때 뒤로 돌아가지 못하도록 막는 문들을 RestrictDoors 배열로 관리했다.

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Room")
TArray<TObjectPtr<ADoorBase>> RestrictDoors;

플레이어가 PrepareTrigger에 들어오면 GameMode에 방 준비를 요청한다.

void ACombatRoom::OnPrepareTriggerBeginOverlap(
    UPrimitiveComponent* OverlappedComponent,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult& SweepResult
)
{
    if (IsValid(OtherActor) && OtherActor->ActorHasTag(TEXT("Player")))
    {
        if (AGunFireGameMode* GFGameMode = GetWorld()
            ? GetWorld()->GetAuthGameMode<AGunFireGameMode>()
            : nullptr)
        {
            GFGameMode->TryPrepareRoom(this);
        }
    }
}

GameMode는 해당 방이 준비 가능한 상태인지 확인한 뒤, 진입하려는 방의 PrepareRoom을 호출한다.

이렇게 해서 플레이어가 통로 중간까지 들어오면 방이 Prepared 상태가 되고,
이전 방으로 돌아가는 문이 닫히며, 몬스터 스폰 같은 준비 처리가 실행된다.


CombatRoom의 PrepareRoom

CombatRoom에서는 PrepareRoom에서 이전 방으로 돌아가는 문을 닫고 전투 준비를 한다.

OnPrepare에서는 몬스터를 스폰하고 남은 적 수를 GameState에 동기화한다.

기존 구조에서는 방에 진입하면서 바로 몬스터가 스폰되고 전투가 시작되었다.
하지만 변경된 구조에서는 통로 중간에서 이전 방으로 돌아가는 문이 닫히고, 방에 완전히 들어오기 전 미리 전투 준비를 할 수 있게 되었다.


방에 완전히 진입했을 때

방에 완전히 진입하면 RoomBaseStartTrigger가 발동된다.

void ARoomBase::OnEntryTriggerBeginOverlap(
    UPrimitiveComponent* OverlappedComponent,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult& SweepResult
)
{
    if (IsValid(OtherActor) && OtherActor->ActorHasTag(TEXT("Player")))
    {
        if (AGunFireGameMode* GFGameMode = GetWorld()
            ? GetWorld()->GetAuthGameMode<AGunFireGameMode>()
            : nullptr)
        {
            GFGameMode->TryEnterRoom(this);
        }
    }
}

GameMode에서는 해당 방이 시작 가능한 상태인지 확인한다.

bool AGunFireGameMode::CanEnterRoom(const ARoomBase* EnteredRoom)
{
    if (!IsValid(EnteredRoom)) return false;

    if (!EnteredRoom->IsWaiting() && !EnteredRoom->IsPrepared()) return false;

    if (IsValid(CurrentRoom) && CurrentRoom->IsInProgress()) return false;

    if (IsValid(CurrentRoom) &&
        CurrentRoom->IsPrepared() &&
        CurrentRoom != EnteredRoom)
    {
        return false;
    }

    return true;
}

방이 Waiting 또는 Prepared 상태일 때만 시작할 수 있고, 이미 다른 방이 진행 중이면 새 방을 시작하지 못하게 했다.

이후 StartCurrentRoom이 호출되면 현재 방 상태를 InProgress로 바꾸고, 실제 방 시작 처리를 수행한다.

void AGunFireGameMode::StartCurrentRoom()
{
    if (!IsValid(CurrentRoom)) return;

    AGunFireGameState* GFGameState = GetGameState<AGunFireGameState>();
    if (!GFGameState) return;

    GFGameState->SetCurrentRoomType(CurrentRoom->GetRoomType());
    GFGameState->SetCurrentRoomState(ERoomState::InProgress);
    GFGameState->SetCurrentRoomID(CurrentRoom->GetRoomID());

    CurrentRoom->StartRoom(this, GFGameState);
}

이 시점에서 RoomBase::StartRoom에 의해 방 입구 쪽 문도 닫히게 된다.


StartRoom 추가

기획이 변경되면서 SafeRoom 대신 StartRoom을 새로 추가했다.

StartRoom은 각 층의 시작 지점이면서, 조건을 만족했을 때 다음 층으로 이동하는 포탈을 활성화하는 역할을 한다.

FloorPortal은 다음 층으로 이동할 포탈이고, PlayerSpawnPoint는 해당 층에서 플레이어가 시작할 위치이다.

BeginPlay에서는 포탈을 비활성화해둔다.

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

    if (IsValid(FloorPortal))
    {
        FloorPortal->SetActive(false);
    }
}

전투방을 모두 클리어하면 GameMode에서 StartRoomActivateFloorPortal을 호출한다.

void AStartRoom::ActivateFloorPortal()
{
    if (IsValid(FloorPortal))
    {
        FloorPortal->SetActive(true);
    }
}

또 3층처럼 시작방에서 바로 다음 진행으로 넘어가야 하는 경우를 위해 bEndImmediately 값도 두었다.
테스트를 할 때에도 해당 값을 켜서 진행하는 방식으로 넘어갈 수 있었다.

void AStartRoom::OnStart(
    AGunFireGameMode* GFGameMode,
    AGunFireGameState* GFGameState
)
{
    if (bEndImmediately)
    {
        GFGameMode->EndStartRoom();
    }
}

이 값이 true라면 StartRoom이 시작되자마자 바로 종료되도록 처리했다.


GameMode에서 StartRoom 찾기

레벨이 시작되면 GameMode는 StartRoom을 찾고, 플레이어를 StartRoom의 스폰 포인트로 이동시킨다.

StartRoom은 TActorIterator를 사용해서 레벨에서 찾는다.

AStartRoom* AGunFireGameMode::FindStartRoom()
{
    if (UWorld* World = GetWorld())
    {
        for (TActorIterator<AStartRoom> It(World); It; ++It)
        {
            AStartRoom* StartRoom = *It;
            if (IsValid(StartRoom))
            {
                return StartRoom;
            }
        }
    }

    return nullptr;
}

이렇게 해서 레벨마다 StartRoom을 배치해두면, GameMode가 시작 시점에 찾아서 플레이어 시작 위치와 포탈 관리를 처리할 수 있다.


전투방 클리어 수로 포탈 활성화하기

기존에는 마지막 방에 도달하거나 NextRooms가 없는 방을 기준으로 다음 층 이동 조건을 판단했다.

하지만 변경된 구조에서는 StartRoom 주변의 CombatRoom을 모두 클리어했는지가 중요하다.

그래서 GameMode에서 전투방 개수를 세고, 클리어한 전투방 개수를 관리하도록 했다.

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Room")
int32 RequiredCombatRoomCount;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Room")
int32 ClearedCombatRoomCount;

레벨 시작 시에는 현재 레벨에 배치된 CombatRoom 개수를 센다.

int32 AGunFireGameMode::CountCombatRooms()
{
    int32 Count = 0;

    if (UWorld* World = GetWorld())
    {
        for (TActorIterator<ACombatRoom> It(World); It; ++It)
        {
            ACombatRoom* CombatRoom = *It;
            if (IsValid(CombatRoom) &&
                CombatRoom->GetRoomType() == ERoomType::Combat)
            {
                ++Count;
            }
        }
    }

    return Count;
}

여기서 TActorIterator<ACombatRoom>ACombatRoom의 자식 클래스까지 찾을 수 있기 때문에,
실제 CombatRoom 역할을 하는 ERoomType::Combat만 세도록 했다.

방이 종료될 때 현재 방이 CombatRoom이면 클리어 카운트를 증가시킨다.

if (CurrentRoom->GetRoomType() == ERoomType::Combat)
{
    ++ClearedCombatRoomCount;
    GFGameState->SetClearedCombatRoomCount(ClearedCombatRoomCount);

    if (ClearedCombatRoomCount >= RequiredCombatRoomCount)
    {
        UE_LOG(LogTemp, Warning, TEXT("모든 전투방 클리어. 포탈을 활성화합니다!!"));
        ActivatePortal();
    }
}

조건을 만족하면 StartRoom에 있는 포탈을 활성화한다.

void AGunFireGameMode::ActivatePortal()
{
    AGunFireGameState* GFGameState = GetGameState<AGunFireGameState>();
    if (!GFGameState) return;

    GFGameState->SetPortalActivated(true);

    if (IsValid(StartingRoom))
    {
        StartingRoom->ActivateFloorPortal();
    }
}

이제 다음 층 이동 조건은 방 연결이 아니라, CombatRoom 클리어 개수로 판단된다.


GameState 동기화

UI나 플레이어에게 보여줄 수 있는 정보는 GameState에 저장하도록 했다.

GameState에는 현재 방 타입, 현재 방 상태, 현재 방 ID, 남은 적 수, 현재 층, 포탈 활성화 여부, 필요한 전투방 수, 클리어한 전투방 수를 저장했다.

새로운 층이 시작될 때는 StartFloor를 호출한다.

void AGunFireGameState::StartFloor(int32 NewFloor, int32 CombatRoomCount)
{
    Initialize();
    CurrentRoomState = ERoomState::InProgress;
    CurrentFloor = NewFloor;
    RequiredCombatRoomCount = CombatRoomCount;
}

GameMode는 방 상태가 바뀔 때마다 GameState도 함께 갱신한다.

예를 들어 방을 준비할 때는 Prepared 상태로 바꾼다.

GFGameState->SetCurrentRoomType(CurrentRoom->GetRoomType());
GFGameState->SetCurrentRoomState(ERoomState::Prepared);
GFGameState->SetCurrentRoomID(CurrentRoom->GetRoomID());
GFGameState->SetRemainingEnemyCount(0);

방이 시작되면 InProgress, 방이 종료되면 Cleared로 변경한다.

이렇게 하면 UI 쪽에서는 GameState를 통해 현재 방의 상태나 전투 진행도를 확인할 수 있다.


CombatRoom의 전투 종료와 방 종료 분리

기존 CombatRoom 구조에서는 몬스터를 모두 처치하면 바로 방을 종료했다.
하지만 변경된 구조에서는 전투가 끝난 뒤 유물 선택 같은 보상 처리가 들어갈 수 있기 때문에 전투 종료와 방 종료를 분리했다.

적이 죽으면 남은 적 수를 줄이고, 0이 되면 바로 GameMode의 EndCurrentRoom을 호출하지 않고 OnClearedCombat을 호출한다.

if (RemainingEnemyCount <= 0)
{
    OnClearedCombat();
}

기본 CombatRoom에서는 OnClearedCombat에서 보상 선택을 시작한다.

void ACombatRoom::OnClearedCombat()
{
    StartSelectReward();
}

StartSelectReward는 BlueprintNativeEvent로 선언했다.

UFUNCTION(BlueprintNativeEvent, Category = "Room|Combat|Reward")
void StartSelectReward();

블루프린트에서 유물 선택 UI를 구현할 수 있고, 선택이 끝나면 CompleteSelectReward를 호출해서 방을 종료한다.

void ACombatRoom::CompleteSelectReward()
{
    if (AGunFireGameMode* GFGameMode = GetWorld()
        ? GetWorld()->GetAuthGameMode<AGunFireGameMode>()
        : nullptr)
    {
        GFGameMode->EndCurrentRoom();
    }
}

아직 유물 선택 시스템이 완전히 연결되지 않은 경우를 위해 C++ 기본 구현에서는 바로 방을 종료하도록 했다.

void ACombatRoom::StartSelectReward_Implementation()
{
    UE_LOG(LogTemp, Error, TEXT("유물 선택 즉시 종료, 유물 시스템 연결 후 해당 구문 제거 필요"));
    CompleteSelectReward();
}

이렇게 하면 지금은 바로 방이 종료되지만, 이후 유물 선택 UI가 구현되면 블루프린트에서 해당 흐름을 대체할 수 있다.


RandomRoom 추가

새로운 구조에서는 RandomRoom도 필요해졌다.

RandomRoom은 CombatRoom을 상속받도록 만들었다.

RandomRoom에는 두 가지 모드가 있다.
Relic 모드라면 전투 없이 유물 선택으로 진행하고, EliteCombat 모드라면 CombatRoom의 전투 로직을 사용한다.

랜덤방이 준비될 때 모드를 결정한다.

void ARandomRoom::OnPrepare(
    AGunFireGameMode* GFGameMode,
    AGunFireGameState* GFGameState
)
{
    RoomMode = DecideRoomMode();

    if (RoomMode == ERandomRoomMode::EliteCombat)
    {
        Super::OnPrepare(GFGameMode, GFGameState);
        return;
    }

    Initialize();
    GFGameState->SetRemainingEnemyCount(0);
    UE_LOG(LogTemp, Warning, TEXT("랜덤방, 유물 준비"));
}

방이 시작되었을 때 유물방이면 바로 유물 선택으로 넘어간다.

void ARandomRoom::OnStart(
    AGunFireGameMode* GFGameMode,
    AGunFireGameState* GFGameState
)
{
    if (RoomMode == ERandomRoomMode::Relic)
    {
        StartSelectReward();
        return;
    }

    Super::OnStart(GFGameMode, GFGameState);
}

강화 전투라면 부모인 CombatRoom의 흐름을 그대로 사용한다.
전투가 끝났을 때는 보상 선택 없이 방을 종료하도록 했다.

void ARandomRoom::OnClearedCombat()
{
    UE_LOG(LogTemp, Warning, TEXT("랜덤방 : 강화 전투 종료"));
    CompleteSelectReward();
}

이렇게 하면 RandomRoom이 CombatRoom의 스폰, 적 사망 처리, 방 종료 흐름을 재사용하면서도, 유물방과 강화 전투방의 차이를 가질 수 있다.


랜덤 유물방 등장 횟수 제한

RandomRoom이 항상 유물방으로만 나오면 보상이 너무 많아질 수 있기 때문에,
한 층에서 유물방이 등장할 수 있는 최대 횟수를 GameMode에서 관리했다.

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Room")
int32 MaxRandomRelicRoomCount;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Room")
int32 CurrentRandomRelicRoomCount;

유물방이 나올 수 있는지 확인하는 함수는 TryGenerateRandomRelicRoom이다.

bool AGunFireGameMode::TryGenerateRandomRelicRoom()
{
    if (CurrentRandomRelicRoomCount >= MaxRandomRelicRoomCount)
    {
        return false;
    }

    ++CurrentRandomRelicRoomCount;
    return true;
}

RandomRoom에서는 먼저 랜덤으로 모드를 정한 뒤, 유물방이 걸렸다면 GameMode에 유물방 생성 가능 여부를 확인한다.

ERandomRoomMode ARandomRoom::DecideRoomMode()
{
    ERandomRoomMode RandomMode = FMath::RandBool()
        ? ERandomRoomMode::Relic
        : ERandomRoomMode::EliteCombat;

    if (RandomMode == ERandomRoomMode::Relic)
    {
        if (AGunFireGameMode* GFGameMode = GetWorld()
            ? GetWorld()->GetAuthGameMode<AGunFireGameMode>()
            : nullptr)
        {
            if (GFGameMode->TryGenerateRandomRelicRoom())
            {
                return ERandomRoomMode::Relic;
            }
        }
    }

    return ERandomRoomMode::EliteCombat;
}

유물방 생성 횟수가 이미 최대치라면 EliteCombat으로 처리한다.


다음 층 이동 처리

포탈이 활성화된 뒤 플레이어가 포탈과 상호작용하면 다음 층으로 이동한다.
GameMode의 TryEnterNextFloor에서는 현재 방이 클리어 상태인지, 포탈이 활성화되어 있는지, 다음 레벨 이름이 지정되어 있는지 확인한다.

void AGunFireGameMode::TryEnterNextFloor(FName NextLevelName)
{
    AGunFireGameState* GFGameState = GetGameState<AGunFireGameState>();
    if (!GFGameState) return;

    if (GFGameState->GetCurrentRoomState() != ERoomState::Cleared) return;
    if (!GFGameState->GetPortalActivated()) return;
    if (NextLevelName.IsNone()) return;

    if (UGunFireGameInstance* GFGameInstance = GetGameInstance<UGunFireGameInstance>())
    {
        GFGameInstance->MoveNextFloor();
        UE_LOG(LogTemp, Error, TEXT("층 변경, 플레이어 정보 인스턴스에 기록 필요"));
    }

    UGameplayStatics::OpenLevel(this, NextLevelName);
}

아직 플레이어 정보 동기화는 추가 작업이 필요하지만, 전체 흐름은 다음과 같다.

CombatRoom 클리어 수 충족
→ StartRoom 포탈 활성화
→ 포탈 상호작용
→ GameInstance의 층 정보 증가
→ 다음 레벨 OpenLevel

실행 예시


정리

이번에는 변경된 기획에 맞춰 Room Flow 구조를 수정했다.

주요 변경점은 다음과 같다.

기존 구조
- SafeRoom에서 시작
- NextRooms로 다음 방 연결
- 방 클리어 후 다음 방 해금
- 마지막 방 기준으로 다음 층 이동

변경 구조
- StartRoom 중심
- 주변에 CombatRoom / RandomRoom 배치
- CombatRoom 클리어 수 기준으로 포탈 활성화
- 문과 통로를 이용해 물리적으로 진행 제한
- Prepared / InProgress / Cleared 상태로 방 진행 관리

이를 위해 다음 클래스들을 수정하거나 추가했다.

RoomBase
- NextRooms 제거
- ERoomState 기반 상태 관리
- EntranceDoors로 방 입구 문 제어

DoorBase
- OpenDoor / CloseDoor를 BlueprintNativeEvent로 처리

StartRoom
- 플레이어 스폰 포인트 관리
- 층 이동 포탈 관리
- 조건 만족 시 포탈 활성화

GameMode
- StartRoom 탐색
- CombatRoom 개수 계산
- CombatRoom 클리어 수 관리
- 포탈 활성화
- 다음 층 이동 처리

GameState
- 현재 방 타입, 상태, ID 관리
- 남은 적 수 관리
- 현재 층, 포탈 활성화 여부 관리
- 필요한 전투방 수와 클리어한 전투방 수 관리

CombatRoom
- PrepareTrigger 추가
- RestrictDoors로 이전 방으로 돌아가는 문 제어
- 전투 종료와 방 종료 분리
- 보상 선택 흐름 추가

RandomRoom
- CombatRoom 상속
- Relic / EliteCombat 모드 분기
- 한 층당 유물방 등장 횟수 제한

마무리

처음 구현했던 Room Flow는 당시 기획에는 맞았지만, 방 구조가 변경되면서 그대로 사용하기는 어려워졌다.

기존에는 방들이 NextRooms로 연결되어 있고, 방을 클리어하면 다음 방을 여는 방식이었다.
하지만 변경된 기획에서는 StartRoom을 중심으로 여러 방이 배치되고, CombatRoom 클리어 수에 따라 StartRoom의 포탈이 활성화된다.

그래서 단순히 방 배치만 바꾸는 것이 아니라, GameMode가 판단하는 조건, RoomBase의 상태 관리,
문 제어 방식, GameState에 동기화할 정보까지 함께 수정해야 했다.

특히 통로 구조 때문에 Prepared 상태가 필요해졌다는 점이 큰 차이였다.

플레이어가 통로 중간에 들어오면 이전 방으로 돌아가는 문이 닫히고 몬스터가 준비되며,
방에 완전히 진입하면 입구 쪽 문이 닫히고 전투가 시작된다.

이 구조를 만들면서 방의 상태를 단순히 “대기 / 진행 중 / 클리어”로만 보는 것이 아니라,
실제 플레이어의 이동 단계에 맞춰 나누는 것이 필요하다고 느꼈다.

또 전투 종료와 방 종료를 분리한 것도 중요한 변경점이었다.
몬스터를 모두 처치했다고 바로 방이 끝나는 것이 아니라, 유물 선택이나 보상 처리가 끝난 뒤 방이 종료되어야 하기 때문이다.

이번 작업을 통해 게임의 진행 방식이 바뀌면 단순히 레벨 배치만 수정하는 것이 아니라,
전체 게임 흐름을 관리하는 코드 구조도 함께 바뀌어야 한다는 것을 느꼈다.