본문으로 바로가기

4장 연습문제 4.1

문제

4장의 게임 프로젝트 코드에서 EnemyTower클래스, 또는 두 클래스 모두 AI 상태 기계를 사용하도록 갱신해본다.

  • 조건1: AI가 가져야 하는 행위를 고려하고 상태 기계 그래프를 설계하자.
  • 조건2: 이런 행위를 구현하고자 제공된 AIComponentAIState 기본 클래스를 사용한다.

풀이

▲ 기본으로 제공된 타워디펜스

4장에 제공된 소스코드에 위의 이미지처럼 기본적인 타워디펜스가 구현되어 있다. 실행해보면 사실 마땅히 적용할 만한 상태 기계가 없어 보인다. Enemy는 이동하고 죽는 것이 전부로 보이고 Tower는 탐색, 공격이 상태의 전부이다. 그래서 상태 기계를 더 풍부하게 구현하기 위해서 Tower에 조준(Lock on)이라는 한 가지 기능을 임의로 더 추가해 보았다.

조준(Lock on)은 적을 발견했을 때 화구를 적 방향으로 유지하는 기능이다. 타워가 쿨타임 중이어서 공격할 수 없을 때 화구를 적의 방향으로 계속 조준하게 된다. 스타크래프트의 미사일 터렛을 생각하면 좋을 듯하다.

Exercise_4_1 전체 소스코드

구현 결과물

▲ 연습문제 4.1 시연 영상

1단계 상태 기계 그래프 설계

조건1: AI가 가져야 하는 행위를 고려하고 상태 기계 그래프를 설계하자.

  • Enemy의 상태 두 가지
    • Move 상태: 목적지에 도착했다면 Death 상태로 전이. 이동과 관련된 NavComponent는 기존대로 Enemy에서 계속 관리하도록 했다.
    • Death 상태: Actor의 상태를 EDead로 설정함으로써 자동으로 Actor가 지워질 수 있도록 한다.
  • Tower의 상태 세 가지
    • Search 상태: 적군을 탐지하는 상태. 적군을 발견했고, 발견한 적군이 사정거리 안으로 들어온다면 타워의 타깃으로 지정하고 LockOn 상태로 전이한다.
    • LockOn 상태: 적군을 조준하고 있는 상태. 타깃이 사라졌다면 다시 Search상태로 전이한다. 적군이 사정거리를 벗어나도 Search상태로 전이한다. 공격 가능 상태가 되면 fire 상태로 전이한다.
    • Fire 상태: 타겟이 존재한다면 조준하고 있는 방향으로 총알을 발사한다. 총알을 발사한 후에는 새로운 적군을 탐색한다.

▲ 상태 기계 그래프 설계

2단계 Enemy의 상태 기계 구현 사전 작업

조건2: 이런 행위를 구현하고자 제공된 AIComponentAIState 기본 클래스를 사용한다.

  • 먼저 AIState에서 Actor의 행동을 조작하기 위해서는 Actor의 인스턴스를 가져올 필요가 있다. 저자가 제공해준 코드에서는 AIState에서 Actor의 인스턴스를 받아올 방법이 없기 때문에 Component class에 GetActor()함수를 추가하자.

Component.h의 선언

class Component
{
public:
    Component(class Actor* owner, int updateOrder = 100);
    virtual ~Component();
    virtual void Update(float deltaTime);
    virtual void ProcessInput(const uint8_t* keyState) {}

    int GetUpdateOrder() const { return mUpdateOrder; }

    //Actor 인스턴스 mOwner 반환
    class Actor* GetActor() { return mOwner; }

protected:
    class Actor* mOwner;
    int mUpdateOrder;
};

3단계 Enemy의 상태 기계 구현

AIMoveAIDeath를 구현해보자. 먼저 AIMove의 역할은 다음과 같다.

  • 목적지에 도착했는지 지속적으로 관찰한다.
  • 목적지에 도착했다면 Death상태로 전이시킨다.
    위 작업을 위해서는 OnEnter()이나 OnExit()에 별다른 역할은 필요 없어 보인다. 따라서 다음과 같이 함수를 구현한다.

AIMove 함수들

void AIMove::Update(float deltaTime)
{
//  SDL_Log("Updating %s state", GetName()); 
    Actor* actor = mOwner->GetActor();
    Vector2 diff = actor->GetPosition() - actor->GetGame()->GetGrid()->GetEndTile()->GetPosition();

    // 목적지에 도착했다면 AIDeath 상태로 전이
    if (Math::NearZero(diff.Length(), 10.0f))
    {
        mOwner->ChangeState("Death");
    }
}

void AIMove::OnEnter()
{
    SDL_Log("Entering %s state", GetName());
}

void AIMove::OnExit()
{
    SDL_Log("Exiting %s state", GetName());
}

다음으로 AIDeath를 구현해보자. AIDeath의 역할은 다음과 같다.

  • 엑터가 삭제될 수 있도록 엑터의 상태를 EDead로 변경해준다.
    AIDeathAIMove와 마찬가지로 OnEnter()OnExit()의 역할은 필요 없으며 AIMove의 것과 동일하여 생략한다.

AIState.cpp의 AIDeath::Update(float)

void AIDeath::Update(float deltaTime)
{
    SDL_Log("Updating %s state", GetName());
    Actor* actor = mOwner->GetActor();
    actor->SetState(Actor::EDead);
}

마지막으로 구현한 상태 기계를 Enemy에 등록하고 EnemyUpdateActor()함수를 수정한다.

Enemy.cpp의 Enemy::Enemy(class Game*)

Enemy::Enemy(class Game* game)
:Actor(game)
{
    // Add to enemy vector
    game->GetEnemies().emplace_back(this);

    SpriteComponent* sc = new SpriteComponent(this);
    sc->SetTexture(game->GetTexture("../Assets/Airplane.png"));
    // Set position at start tile
    SetPosition(GetGame()->GetGrid()->GetStartTile()->GetPosition());
    // Setup a nav component at the start tile
    NavComponent* nc = new NavComponent(this);
    nc->SetForwardSpeed(150.0f);
    nc->StartPath(GetGame()->GetGrid()->GetStartTile());
    // Setup a circle for collision
    mCircle = new CircleComponent(this);
    mCircle->SetRadius(25.0f);

    // 인공지능 상태 등록
    AIComponent* aic = new AIComponent(this);
    aic->RegisterState(new AIMove(aic));
    aic->RegisterState(new AIDeath(aic));
    // Move 상태로 설정
    aic->ChangeState("Move");
}

Enemy.cpp의 Enemy::UpdateActor(float)

void Enemy::UpdateActor(float deltaTime)
{
    Actor::UpdateActor(deltaTime);
    // 기존의 코드 제거
}

4단계 Tower의 조준(Lock on) 기능 구현을 위한 사전 작업

Tower의 조준 기능을 구현하기 위해서 기본 코드에 몇 가지 수정이 필요하다. 먼저 Tower의 멤버 변수로 class Enemy* mTarget을 추가하자 적군을 발견 후 계속 조준하기 위해서는 탐색된 적군을 타깃으로 지정해둘 필요가 있다.

Tower.h의 선언

class Tower : public Actor
{
public:
    Tower(class Game* game);
    void UpdateActor(float deltaTime) override;
    // 타겟 Getter / Setter
    void SetTarget(class Enemy* target) { mTarget = target; }
    class Enemy* GetTarget(){ return mTarget; }

private:
    class MoveComponent* mMove;
    float mNextAttack;
    const float AttackTime = 2.5f;
    const float AttackRange = 100.0f;
    class Enemy* mTarget;   // 멤버변수 타겟 추가
};

적군은 언제든지 죽을 수 있다. 그리고 죽게 되면 메모리에서 해제가 되는데, 만약 조준 중이던 적군이 죽어서 해제가 되었는데, 타워가 계속 mTarget을 참조하게 된다면 메모리 참조 오류를 일으킨다. 따라서 ~Enemy()에서 자신을 타깃으로 참조하고 있는 타워의 mTargetnullptr로 만들 필요가 있다. 그러기 위해서 다음과 같은 수정이 필요하다.

  • Game 클래스에 벡터 타입 멤버 변수 mTowers 만들고
  • Tower의 생성자는 자신의 인스턴스를 mTowers 벡터에 삽입해야 한다.
  • 마지막으로 ~Enemy()에서 자신을 타깃으로 지정한 Tower를 탐색하여 모두 nullptr로 지정한다.

Tower.cpp의 Tower::Tower(class Game*)

Tower::Tower(class Game* game)
:Actor(game)
{
    // mTowers 벡터에 등록
    game->GetTowers().emplace_back(this);
    SpriteComponent* sc = new SpriteComponent(this, 200);
    sc->SetTexture(game->GetTexture("../Assets/Tower.png"));

    mMove = new MoveComponent(this);
    //mMove->SetAngularSpeed(Math::Pi);

    mNextAttack = AttackTime;
}

Enemy.cpp의 Enemy::~Enemy()

Enemy::~Enemy()
{
    // Remove from enemy vector
    auto iter = std::find(GetGame()->GetEnemies().begin(),
                        GetGame()->GetEnemies().end(),
                        this);
    GetGame()->GetEnemies().erase(iter);

    // 모든 타워 인스턴스에서 
    // 삭제할 this(적군)를 타겟으로 가진 타워는 타겟을 nullptr로 설정
    // for_each의 3번째 인자를 람다식으로 표현
    std::for_each(GetGame()->GetTowers().begin(),
                GetGame()->GetTowers().end(),
                [this](Tower* tower) { 
                    if (tower->GetTarget() == this)
                        tower->SetTarget(nullptr);
                }); 
}

5단계 Tower의 상태 기계 구현

  • Tower의 상태 세 가지
    • Search 상태: 적군을 탐지하는 상태. 적군을 발견했고, 발견한 적군이 사정거리 안으로 들어온다면 타워의 타깃으로 지정하고 LockOn 상태로 전이한다.
    • LockOn 상태: 적군을 조준하고 있는 상태. 타깃이 사라졌다면 다시 Search상태로 전이한다. 적군이 사정거리를 벗어나도 Search상태로 전이한다. 공격 가능 상태가 되면 fire 상태로 전이한다.
    • Fire 상태: 타겟이 존재한다면 조준하고 있는 방향으로 총알을 발사한다. 총알을 발사한 후에는 새로운 적군을 탐색한다.

Tower에서도 Enemy와 마찬가지로 OnEnterOnExit은 별도의 수정을 하지 않았다. 다만 타워의 AIState 들은 타워의 인스턴스에 접근할 수 있도록 생성자에서부터 의존성 주입 패턴을 활용하였다.

예시로 Search class의 선언 부분을 살펴보겠다. 나머지의 class도 동일하게 수정하였기에 생략한다.

AIState.h 의 AISearch 클래스 선언

class AISearch : public AIState
{
public:
    AISearch(class AIComponent* owner, class Tower* tower)
        :AIState(owner)
        ,mTower(tower)
    { }

    void Update(float deltaTime) override;
    void OnEnter() override;
    void OnExit() override;

    const char* GetName() const override
    { return "Search"; }

private:
    class Tower* mTower;
};

이어서 Search, LockOn, Fire 각각의 Update 함수들이다.

AIState.h 의 AISearch::Update(float)

void AISearch::Update(float deltaTime)
{
    // SDL_Log("Updating %s state", GetName());

    // 타겟을 탐색한다.
    Enemy* target = mTower->GetGame()->GetNearestEnemy(mTower->GetPosition());
    // 타겟을 발견했다면
    if (target != nullptr) {

        // 타겟과의 거리를 계산한다.
        Vector2 dir = target->GetPosition() - mTower->GetPosition();
        float dist = dir.Length();

        //  타겟이 사정거리 안에 들었다면 LockOn 상태로 전이
        if (dist < mTower->GetAttackRange()) {
            mTower->SetTarget(target);
            mOwner->ChangeState("LockOn");
        }
    }    
}

AIState.h 의 AILockOn::Update(float)

void AILockOn::Update(float deltaTime)
{
    // SDL_Log("Updating %s state", GetName());
    Enemy* target = mTower->GetTarget();
    // 타겟이 사라졌다면 Search 상태로 전이한다.
    if (target == nullptr)
        mOwner->ChangeState("Search");
    // 타겟이 존재할 경우
    else
    {
        // 타겟을 향해서 회전
        Vector2 dir = target->GetPosition() - mTower->GetPosition();
        mTower->SetRotation(Math::Atan2(-dir.y, dir.x));

        // 타겟과의 거리를 계산
        float dist = dir.Length();

        // 타겟이 사정거리를 벗어났다면 
        if (dist > mTower->GetAttackRange()) {
            mOwner->ChangeState("Search");
        }
        // 사거리 안에 있으면서, 쿨타임이 지났다면
        else if (mTower->GetNextAttack() < 0)
        {
            // Fire 상태로 전이
            mOwner->ChangeState("Fire");
        }

        // 사거리 안에 있지만 쿨타임일 경우 LockOn 상태 유지
    }
}

AIState.h 의 AIFire::Update(float)

void AIFire::Update(float deltaTime)
{
    // SDL_Log("Updating %s state", GetName());
    Enemy* target = mTower->GetTarget();

    // 타겟이 존재한다면 총알을 발사
    if (target != nullptr) {
        // 타워가 조준하고 있는 방향으로 bullet 생성
        Bullet* b = new Bullet(mTower->GetGame());
        b->SetPosition(mTower->GetPosition());
        b->SetRotation(mTower->GetRotation());
        // 쿨타임을 초기화 한다.
        mTower->SetNextAttack();
    }
    // 새로운 적군을 찾을 수 있도록 Search 상태로 전이
    mOwner->ChangeState("Search");
}

마지막으로 Tower class의 멤버 함수 Tower::UpdateActor(float) 함수를 아래와 같이 수정한다.

Tower.cpp 의 Tower::UpdateActor(float)

void Tower::UpdateActor(float deltaTime)
{
    Actor::UpdateActor(deltaTime);

    // mNextAttack을 제외한기존코드 모두 삭제
    mNextAttack -= deltaTime;
}

 

이상으로 구현을 마친다.

 

평가

Lock on 기능 추가해서 타워가 부드럽게 움직이는 모습이 만족스러웠다.

 

이번 연습문제를 해결하기 위해 상태 기계 클래스를 구현하는 과정에서 가장 크게 느꼈던 장점은 엑터의 기능별로 코드를 분리할 수 있었다는 점이다. 지금은 타워의 상태라고 해봐야 3개 정도밖에 없지만, 실제 게임에서는 이동, 순찰, 공격 준비, 공격, 피격, 대기, 대화 등 훨씬 더 많은 상태가 존재할 수 있다. 만약 이 많은 상태를 엑터의 Update() 함수에 넣는다면 가독성이 굉장히 떨어질 것이다. 실제 이번 학습 이전까지만 해도 내가 게임을 그렇게 만들고 있기도 했다. 상태 기계는 각 상태마다의 Update()함수를 따로 관리함으로써 코드를 상태의 기능별로 추상화할 수 있었고, 그만큼 가독성을 향상할 수 있었다.