본 프로젝트는 C++ 베이스의 Text RPG로 3D, Isometric 뷰를 가지는 RPG 게임입니다.


Tick 분배

컴포넌트 구조를 쓴 이유는 단순히 파일을 나누기 위해서가 아니라, 업데이트 책임을 분리하기 위해서다.
AObject::Tick()은 공통 상태를 처리하고, 그 다음 컴포넌트들에게 Tick을 분배한다.

void AObject::Tick(float DeltaTime)
{
    if (HitFlashTime > 0.0f) { ... }
    if (DamageTextTime > 0.0f) { ... }
    if (SlowTime > 0.0f) { ... }

    const float ScaledDeltaTime = DeltaTime * GetSlowRatio();

    if (!Components.empty())
    {
        for (int i = 0; i < Components.size(); ++i)
        {
            if (Components[i])
            {
                Components[i]->Tick(ScaledDeltaTime);
            }
        }
    }
}

이 구조 덕분에 이동은 MoveComponent, 공격 판정은 CombatComponent, 경험치나 레벨업은 다른 컴포넌트가 나눠서 처리할 수 있었다.
특히 Slow 같은 상태 이상을 공통 처리한 뒤, ScaledDeltaTime으로 각 컴포넌트에 내려보내는 방식은 오브젝트 단위 상태와 기능 단위 상태를 분리하기 좋았다.


이동과 전투

MoveComponent는 입력과 위치 변경만 담당한다.

void UMoveComponent::Tick(float DeltaTime)
{
    if(nullptr == PlayerPtr)
        PlayerPtr = dynamic_cast(GetOwner());

    if (PlayerPtr == nullptr)
    {
        return;
    }

    MoveElapsedTime += DeltaTime;
    TurnElapsedTime += DeltaTime;

    if (MoveElapsedTime < MoveInterval)
    {
        return;
    }

    if (InputManager::GetInstance()->IsKeyPressed(KeyCode::UP))
    {
        PlayerPtr->SetPrevPosition(PlayerPtr->GetPosition());
        PlayerPtr->SetPosition({ PlayerPtr->GetPosition().X, max(PlayerPtr->GetPosition().Y - 1, 0) });
        SetFacingDirection(EDirection::UP);
        MoveElapsedTime = 0.0f;
    }
    ...
}

여기서는 맵 판정이나 전투를 같이 하지 않는다.
오로지 입력, 이전 위치, 현재 위치, 방향 전환만 처리한다.

반대로 CombatComponent는 공격 타이밍과 공격 범위만 담당한다.

void UCombatComponent::Tick(float DeltaTime)
{
    TotalTime += DeltaTime;

    if (TotalTime < DelayTime)
        return;

    if (InputManager::GetInstance()->IsKeyTap(KeyCode::Z))
    {
        AttackValue.clear();

        switch (MoveComponentPtr->GetFacingDirection())
        {
        case EDirection::UP:
            AttackValue.push_back({ OwnerPosition.X, OwnerPosition.Y - 1 });
            break;
        ...
        }
    }
}

AttackValue.clear()가 최근에 다시 들어간 것도 같은 맥락이다.
공격 범위를 매 프레임 누적해두면 이전 공격 정보가 남아서 판정이 틀어지기 때문에, 입력이 발생하는 시점마다 현재 공격 상태만 다시 계산하도록 정리한 것이다.


풀링 구조

프로젝트가 커질수록 몬스터나 투사체를 계속 new / delete 하는 방식은 관리가 지저분해진다.
그래서 여기서는 ObjectPoolManager를 따로 두고, 타입별 풀을 만드는 방식으로 정리했다.

핵심 구조는 type_index를 키로 해서 타입별 풀을 분리하는 부분이다.

unordered_map> Pools;
unordered_map PoolByObject;
unordered_map ObjectByID;

각 풀은 실제 객체 목록과 사용 가능한 객체 목록을 따로 가진다.

template 
struct TypePool : ITypePool
{
    vector> Objects;
    vector Available;
    unordered_set AvailableSet;
};

Get()은 먼저 사용 가능한 객체가 있는지 확인하고, 있으면 꺼내서 다시 활성화한다.

template 
T* Get(Args&&... args)
{
    TypePool* pool = GetOrCreatePool();

    if (!pool->Available.empty())
    {
        T* object = pool->Available.back();
        pool->Available.pop_back();
        pool->AvailableSet.erase(object);
        ObjectByID[object->GetID()] = object;
        object->OnSpawnFromPool();
        return object;
    }

    unique_ptr newObject(new T(std::forward(args)...));
    T* rawObject = newObject.get();
    pool->Objects.push_back(move(newObject));
    PoolByObject[rawObject] = pool;
    ObjectByID[rawObject->GetID()] = rawObject;
    rawObject->OnSpawnFromPool();

    return rawObject;
}

반대로 반환할 때는 OnReturnToPool()을 먼저 호출해서 상태를 초기화한다.

void ReturnObject(AObject* object) override
{
    T* typedObject = static_cast(object);
    if (typedObject == nullptr || AvailableSet.count(typedObject) > 0)
    {
        return;
    }

    typedObject->OnReturnToPool();
    Available.push_back(typedObject);
    AvailableSet.insert(typedObject);
}

오브젝트 쪽에서도 풀링 생명주기를 받을 수 있게 훅을 넣어뒀다.

void AObject::OnSpawnFromPool()
{
    bIsDestroy = false;
    LastDamage = 0;
    HitFlashTime = 0.0f;
    DamageTextTime = 0.0f;
    SlowTime = 0.0f;
}

void AObject::OnReturnToPool()
{
    bIsDestroy = true;
    LastDamage = 0;
    HitFlashTime = 0.0f;
    DamageTextTime = 0.0f;
    SlowTime = 0.0f;
}

풀 매니저는 객체의 수명 관리만 담당하고, 객체는 자기 상태 초기화만 신경 쓰면 된다.


Render 분리

ViewportManager는 무엇을 그릴지 정리하고, RenderManager는 실제 콘솔 버퍼에 찍어낸다.

void ViewportManager::Render()
{
    RenderObject();
    RenderUI();
}

void ViewportManager::RenderObject()
{
    Render2DtoISO();
}

그 다음 RenderManager가 마지막 출력만 담당한다.

void RenderManager::Render()
{
    DrawScreen();
    ClearScreen();
}

SceneManager, BattleManager, MapManagerTick에서 상태를 바꾸고 나면, ViewportManager는 그 상태를 읽어서 ISO 시점으로 조립하고, RenderManager는 조립된 결과를 화면에 밀어 넣는다.