4장 연습문제 4.1
문제
4장의 게임 프로젝트 코드에서 Enemy
나 Tower
클래스, 또는 두 클래스 모두 AI 상태 기계를 사용하도록 갱신해본다.
- 조건1: AI가 가져야 하는 행위를 고려하고 상태 기계 그래프를 설계하자.
- 조건2: 이런 행위를 구현하고자 제공된
AIComponent
와AIState
기본 클래스를 사용한다.
풀이
4장에 제공된 소스코드에 위의 이미지처럼 기본적인 타워디펜스가 구현되어 있다. 실행해보면 사실 마땅히 적용할 만한 상태 기계가 없어 보인다. Enemy는 이동하고 죽는 것이 전부로 보이고 Tower는 탐색, 공격이 상태의 전부이다. 그래서 상태 기계를 더 풍부하게 구현하기 위해서 Tower에 조준(Lock on)이라는 한 가지 기능을 임의로 더 추가해 보았다.
조준(Lock on)은 적을 발견했을 때 화구를 적 방향으로 유지하는 기능이다. 타워가 쿨타임 중이어서 공격할 수 없을 때 화구를 적의 방향으로 계속 조준하게 된다. 스타크래프트의 미사일 터렛을 생각하면 좋을 듯하다.
구현 결과물
1단계 상태 기계 그래프 설계
조건1: AI가 가져야 하는 행위를 고려하고 상태 기계 그래프를 설계하자.
Enemy
의 상태 두 가지Move
상태: 목적지에 도착했다면Death
상태로 전이. 이동과 관련된NavComponent
는 기존대로Enemy
에서 계속 관리하도록 했다.Death
상태:Actor
의 상태를EDead
로 설정함으로써 자동으로Actor
가 지워질 수 있도록 한다.
Tower
의 상태 세 가지Search
상태: 적군을 탐지하는 상태. 적군을 발견했고, 발견한 적군이 사정거리 안으로 들어온다면 타워의 타깃으로 지정하고LockOn
상태로 전이한다.LockOn
상태: 적군을 조준하고 있는 상태. 타깃이 사라졌다면 다시Search
상태로 전이한다. 적군이 사정거리를 벗어나도Search
상태로 전이한다. 공격 가능 상태가 되면fire
상태로 전이한다.Fire
상태: 타겟이 존재한다면 조준하고 있는 방향으로 총알을 발사한다. 총알을 발사한 후에는 새로운 적군을 탐색한다.
2단계 Enemy의 상태 기계 구현 사전 작업
조건2: 이런 행위를 구현하고자 제공된 AIComponent
와 AIState
기본 클래스를 사용한다.
- 먼저
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의 상태 기계 구현
AIMove
와 AIDeath
를 구현해보자. 먼저 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
로 변경해준다.AIDeath
도AIMove
와 마찬가지로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
에 등록하고 Enemy
의 UpdateActor()
함수를 수정한다.
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()
에서 자신을 타깃으로 참조하고 있는 타워의 mTarget
을 nullptr
로 만들 필요가 있다. 그러기 위해서 다음과 같은 수정이 필요하다.
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
와 마찬가지로 OnEnter
와 OnExit
은 별도의 수정을 하지 않았다. 다만 타워의 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()
함수를 따로 관리함으로써 코드를 상태의 기능별로 추상화할 수 있었고, 그만큼 가독성을 향상할 수 있었다.