팩맨 프로토타입

키워드
데모
윈도우
 
 

개요

notion image
제가 직접 개발 중인 자체 엔진을 가지고 팩맨을 구현하였습니다. 게임 클리어 조건 없이 고스트의 FSM 구현과 길찾기 알고리즘을 잘 볼 수 있을 정도로만 구현한 점 양해 부탁드립니다.

프로그램 전체 구조 및 코드 설명

💡
여기서 설명하는 소스코드는 엔진보단 데모 위주로 설명합니다.
게임 엔진의 전반적인 코어 구조는 해당 링크를 통해 확인하실 수 있습니다. 먼저 게임 데모 프로젝트에 사용된 구조를 설명합니다.
notion image
 
씬에 배치된 오브젝트와 클래스는 위 다이어그램과 같습니다.
맵오브젝트와 플레이어, 고스트, 환경 오브젝트가 존재하고, 게임 매니저에서 전체적인 게임 로직을 판단합니다.
아래부터는 프로젝트 내 주요코드를 다룹니다.

길찾기 알고리즘 코드

bool MapDataUtil::SetPathToFind(std::list<CSE::ivec2>& src, std::list<CSE::ivec2>& history, CSE::ivec2 current, CSE::ivec2 start, CSE::ivec2 end) { auto tilePathList = GetTilePath(current, true); if (tilePathList.empty()) return false; // 파인더 뇌절 방지 선 if(history.size() > std::abs(start.x - end.x) + std::abs(start.y - end.y) + 10) { return false; } struct ValueStruct { float value; ivec2 nextIndex; }; auto valueList = std::vector<ValueStruct>(); valueList.reserve(tilePathList.size()); for (const Direction direction : tilePathList) { auto nextIndex = ivec2(); switch (direction) { case ::NONE: return false; case LEFT: nextIndex = { current.x - 1, current.y }; break; case RIGHT: nextIndex = { current.x + 1, current.y }; break; case UP: nextIndex = { current.x, current.y - 1 }; break; case DOWN: nextIndex = { current.x, current.y + 1 }; break; } if (nextIndex.x == end.x && nextIndex.y == end.y) { src.emplace_back(end.x, end.y); src.emplace_back(current.x, current.y); return true; } valueList.push_back( ValueStruct{ static_cast<float>(history.size() + ivec2::Distance(nextIndex, end)), nextIndex }); } std::sort(valueList.begin(), valueList.end(), [] (ValueStruct first, ValueStruct second) -> bool { return first.value < second.value; }); for (const auto& value : valueList) { bool isNewPath = true; for (const auto& history_path : history) { if(current.x == history_path.x && current.y == history_path.y) { isNewPath = false; break; } } if(!isNewPath) continue; history.push_back(current); if(SetPathToFind(src, history, value.nextIndex, start, end)) { src.emplace_back(current.x, current.y); return true; } history.pop_back(); } return false; }
저는 A* 알고리즘에서 제 임의대로 변형시킨 알고리즘을 사용하였습니다.
일반적으로 잘 알려진 A* 알고리즘은 갈 수 있는 노드들을 모두 저장하고 목표에 달성하면 길찾기로 수집했던 동선에서 빠르게 갈 수 있는 노선을 찾는 형식이였지만
저 같은 경우엔 확인한 동선은 모두 저장하지 않고 하나의 길로 쭉 탐색하다가 일정 거리 이상이 되면 다른 동선을 파악하는 좀 더 빠르고 나름 최적의 노선을 택하는 것 처럼 보이도록 커스텀 하였습니다.
따라서 해당 알고리즘은 일반적인 A* 알고리즘과 다르게 맵 데이터에 지나왔던 노선과 F값을 따로 저장하지 않는 점이 특징이 되겠습니다.
이러한 방식으로 구현했던 이유가 사용하는 데이터 경량화와 빠른 탐색도 있었지만 생각보다 구불구불한 노선으로 길을 찾는 것이 팩맨 오리지널의 길찾기 형식같은 느낌과 비슷한게 마음에 들어 이렇게 구현하게 되었습니다.

FSM 시스템

class GhostFsmManagerComponent : public CSE::SComponent { public: GhostFsmManagerComponent(); ~GhostFsmManagerComponent() override; [...] template <class T> // 탬플릿으로 State를 구분하여 상태를 변화시킵니다. void ChangeState(); [...] }; template <class T> // 탬플릿으로 State를 구분하여 상태를 변화시킵니다. void GhostFsmManagerComponent::ChangeState() { DisableAllComponents(); // 모든 State 클래스를 비활성화 합니다. for (const auto& component : m_components) { if(component == m_currentState) continue; if(dynamic_cast<T*>(component)) { // 클래스 형식과 맞는 State를 찾습니다. if(m_currentState != nullptr) m_currentState->EndState(); // 기존 State의 EndState를 호출합니다. m_currentState = component; m_currentState->SetIsEnable(true); m_currentState->StartState(); // 새로운 State의 StartState를 호출합니다. } } }
FSM 시스템은 생각보다 정말 간단합니다. 말 그대로 고스트의 FSM만 담당하기 때문에 주요코드는 State를 바꾸는 ChangeState함수가 전부라고 봐도 무방합니다. 상태 교체 방식은 클래스 형식을 통해 교체하는 간단한 방식입니다.
장점은 상태 교체 시 정말 간단하게 작성이 가능하지만 단점은 관련 상태 클래스의 헤더파일의 include가 필수고, RTTI 기능이 활성화 되어야 사용이 가능합니다.
나머지 FSM 상태 클래스는 아래에서 상세하게 알아보도록 하겠습니다.

FSM 상태 전이도 및 상세 설명

고스트 오브젝트의 클래스 다이어그램
고스트 오브젝트의 클래스 다이어그램
고스트 오브젝트는 FSM을 가지고 플레이어나 게임 상황에 따라 다양하게 반응하도록 설계하였습니다. FSM의 상태변화는 클래스 단위로 구분지었으며, 현재 FSM 상태 클래스 제외하고 나머지 상태 클래스는 모두 비활성화 처리로 로직을 구현하였습니다.
FSM 상태 클래스의 형태는 아래와 같습니다.
class GhostFsmStateBaseComponent : public CSE::SComponent { public: GhostFsmStateBaseComponent(); ~GhostFsmStateBaseComponent() override; void Init() override; // 컴포넌트가 처음 실행 시 한번만 호출하는 함수입니다. void Tick(float elapsedTime) override; // 매 프레임마다 한번씩 호출하는 함수입니다. void Exterminate() override; virtual void StartState() = 0; // FSM 시스템에서 상태가 활성화 되기 전 호출됩니다. virtual void EndState() = 0; // FSM 시스템에서 상태가 비활성화 되기 전 호출됩니다. void SetFsmManager(GhostFsmManagerComponent* manager); // FSM매니저에 등록 시 호출됩니다. protected: GhostFsmManagerComponent* m_fsmManager = nullptr; GhostMovementComponent* m_movementComponent = nullptr; };
해당 코드의 클래스는 FSM의 상태를 담당하는 상태 클래스의 기본 클래스입니다. 해당 클래스를 상속 받아 인터페이스를 유지시켜 객체화 작업하는데 큰 도움을 줍니다.
해당 함수들은 FSM 상태에 대비하여 쉽게 커스텀이 가능하도록 하기 위해 위와 같이 작성되었으며 돌아가는 로직은 아래와 같습니다.
notion image
고스트의 FSM 상태는 아래와 같이 정리하였습니다.
  • GhostInBoxState (In-Box State)
    • void GhostInBoxState::Init() { GhostFsmStateBaseComponent::Init(); } void GhostInBoxState::Tick(float elapsedTime) { GhostFsmStateBaseComponent::Tick(elapsedTime); if(m_startTime <= 0) m_startTime = elapsedTime; if(elapsedTime - m_startTime > m_delayMilliseconds) { m_fsmManager->ChangeState<GhostNoneState>(); } } void GhostInBoxState::StartState() {} void GhostInBoxState::EndState() {}
      고스트가 처음 박스 안에 들어있는 경우를 말합니다. 이 때는 플레이어에게 아무런 영향을 주지 않고, 고스트도 움직이지 않습니다.
  • GhostNoneState (Hunter State)
    • void GhostNoneState::Init() { GhostFsmStateBaseComponent::Init(); auto player_object = gameObject->Find("player object"); m_playerTransform = player_object->GetTransform(); m_playerState = player_object->GetComponent<PlayerStateComponent>(); m_transform = gameObject->GetTransform(); } void GhostNoneState::Tick(float elapsedTime) { GhostFsmStateBaseComponent::Tick(elapsedTime); if(m_playerState->GetState() == PlayerStateComponent::Hunt) { m_fsmManager->ChangeState<GhostHuntedState>(); // 플레이어가 파워 필을 먹으면 상태 전환 return; } if(m_startTime <= 0) m_startTime = elapsedTime; m_movementComponent->UpdatePath(); // 길찾기로 도착하면 다시 갱신하는 함수 m_fsmManager->UpdateCollisionWithPlayer(); // 플레이어와의 충돌 처리 if(elapsedTime - m_startTime < m_safeChangeMilliseconds) return; if(vec3::Distance(m_playerTransform->m_position, m_transform->m_position) < m_chaseRadius) { m_fsmManager->ChangeState<GhostChaseState>(); // 일정 거리에 들어오면 쫓는 상태로 전환 } } void GhostNoneState::StartState() { m_movementComponent->SetPathRandom(); // 랜덤하게 이동하도록 선언 } void GhostNoneState::EndState() { m_startTime = 0; }
      고스트가 맵을 랜덤하게 돌아다니는 경우를 말합니다. 고스트의 기본적인 상태이며, 플레이어와 어느정도 거리 안에서 감지된다면 GhostChaseState로 넘어갑니다.
  • GhostChaseState (Hunter State)
    • void GhostChaseState::Init() { GhostFsmStateBaseComponent::Init(); auto player_object = gameObject->Find("player object"); m_playerTransform = player_object->GetTransform(); m_playerState = player_object->GetComponent<PlayerStateComponent>(); m_transform = gameObject->GetTransform(); m_random = std::uniform_int_distribution<int>(1000, 20000); } void GhostChaseState::Tick(float elapsedTime) { GhostFsmStateBaseComponent::Tick(elapsedTime); if(m_playerState->GetState() == PlayerStateComponent::Hunt) { m_fsmManager->ChangeState<GhostHuntedState>(); // 플레이어가 파워 필을 먹으면 상태 전환 return; } if(m_startTime <= 0) m_startTime = elapsedTime; m_movementComponent->UpdatePath(); // 길찾기로 도착하면 다시 갱신하는 함수 m_fsmManager->UpdateCollisionWithPlayer(); // 플레이어와의 충돌 처리 if(vec3::Distance(m_playerTransform->m_position, m_transform->m_position) > m_chaseRadius || elapsedTime - m_startTime > m_durationSeconds * 1000.f) { // 일정 거리를 벗어나거나 일정 시간이 지나면 원래 상태로 전환 m_fsmManager->ChangeState<GhostNoneState>(); } } void GhostChaseState::StartState() { m_movementComponent->SetTargetState(GhostMovementComponent::Player); // 플레이어로 가도록 경로 지정 m_durationSeconds = m_random(generatorChase) / 1000.f; } void GhostChaseState::EndState() { m_startTime = 0; }
      고스트가 플레이어를 본격적으로 쫓아다니는 경우를 말합니다. 길찾기는 오로지 플레이어에 집중해있으며, 1초~20초 사이에 랜덤한 시간동안 플레이어를 쫓아다니다가 GhostNoneState상태로 돌아가며 추격전을 종료합니다. 한번 추격이 끝나면 10초간 쿨타임이 적용됩니다.
  • GhostHuntedState (Hunted State)
    • void GhostHuntedState::Init() { GhostFsmStateBaseComponent::Init(); m_playerTransform = gameObject->Find("player object")->GetTransform(); m_transform = gameObject->GetTransform(); const auto& renderComponent = GetRenderComponent(*gameObject); // 렌더 컴포넌트를 찾는 함수 m_material = renderComponent->GetMaterial(); } void GhostHuntedState::Tick(float elapsedTime) { GhostFsmStateBaseComponent::Tick(elapsedTime); if(m_startTime <= 0) m_startTime = elapsedTime; m_movementComponent->UpdatePath(); // 길찾기로 도착하면 다시 갱신하는 함수 if(vec3::Distance(m_playerTransform->m_position, m_transform->m_position) <= 1.f) { m_fsmManager->ChangeState<GhostEatenState>(); // 잡히면 박스로 돌아가는 상태로 전환 return; } if(elapsedTime - m_startTime > m_durationSeconds * 1000.f) { m_fsmManager->ChangeState<GhostNoneState>(); // 일정 시간이 지나면 원래 상태로 전환 } } void GhostHuntedState::StartState() { m_movementComponent->SetSpeed(0.03f); // 이속 감소 m_movementComponent->SetPathRandom(); // 랜덤하게 이동하도록 수정 m_material->SetVec3("vec3.albedo", vec3{ 0.01f, 0.01f, 0.01f }); // 고스트를 회색으로 전환 } void GhostHuntedState::EndState() { m_movementComponent->SetSpeed(0.06f); // 이속 복구 m_material->SetVec3("vec3.albedo", vec3{ 1, 1, 1 }); // 고스트 색상 복구 m_startTime = 0; }
      플레이어가 역공을 할 수 있는 아이템을 먹었을 경우를 말합니다. 이 역시 맵을 랜덤하게 다니며, 일정거리 안에 플레이어가 있어도 플레이어를 쫓아가지 않습니다. 이속이 느려지고 고스트가 회색 빛으로 변합니다.
  • GhostEatenState (Eaten State)
    • void GhostEatenState::Init() { GhostFsmStateBaseComponent::Init(); m_transform = gameObject->GetTransform(); m_startTilePosition = MapDataUtil::GetPositionToIndex(m_startIndex); } void GhostEatenState::Tick(float elapsedTime) { GhostFsmStateBaseComponent::Tick(elapsedTime); if(vec3::Distance(m_startTilePosition, m_transform->m_position) <= 0.5f) { m_fsmManager->ChangeState<GhostNoneState>(); // 박스로 되돌아가면 원래 상태로 전환 } } void GhostEatenState::StartState() { m_movementComponent->SetSpeed(0.2f); // 빠르게 복귀하도록 이속을 증가 m_movementComponent->SetTargetState(GhostMovementComponent::None); // 특정 위치로 이동하도록 타겟 설정 m_movementComponent->SetPath(m_startIndex); // 박스 위치로 이동 위치 지정 } void GhostEatenState::EndState() { m_movementComponent->SetSpeed(0.06f); // 이속 복구 }
      고스트가 GhostHuntedState상태에서 플레이어가 잡으면 다시 박스 안으로 되돌아가는 경우를 말합니다. 이 때 이속이 가장 빠르며, 길찾기는 박스 안으로 설정되어 되돌아갑니다. 되돌아 가는 도 중에 플레이어와 충돌이 일어나도 서로 영향을 주지 않습니다.
 
 

실행 결과

프로그램 실행 메뉴얼

해당 프로그램은 다음과 같은 라이브러리와 개발환경에서 제작되었기 때문에 다음과 같은 추가 파일이 필요할 수 있습니다.
  • glew32.dll : OpenGL의 확장 기능을 지원하도록 도와주는 제 3자 그래픽스 라이브러리
  • 비주얼 스튜디오 2019 윈도우 10 : 관련 DLL파일이 없으면 작동하지 않을 수 있는 점 양해 부탁드립니다.
 

실행 메뉴얼

notion image
압축파일을 풀고 CSEngine - Pacman Demo.exe 를 실행시키면 됩니다.

조작키

  • W, A, S, D : 이동 방향키

오류 로그 및 해결 방법

1. 길찾기 알고리즘의 오류

A* 길찾기 알고리즘을 사용하였으나 일부 계산된 값이 이상한 결과로 나오는 경우가 있었습니다.
위와 같은 수식으로 작성하였으나 사실 시작위치에서 현재 위치까지의 휴리스틱이 아닌 지나왔던 타일 횟수를 통한 실질적으로 움직인 거리를 사용해야한다는 사실을 까먹고 있었습니다.
history.size() + ivec2::Distance(nextIndex, end) // 지나온 실질적 거리 + 목적지 까지의 휴리스틱 값
문제를 발견하고 수정한 최종코드는 위와 같습니다.