Skip to content

Tutorial_Step3_C

juns2lee edited this page Nov 12, 2018 · 1 revision

(초급 단계) 일꾼으로 자원 채취하기

TutorialLevel3Bot 프로젝트를 통해, 일꾼으로 자원 채취하기를 해보겠습니다.

개발 범위 정의

  1. 일꾼으로 자원 채취하기

대결이 시작되었을 때 제일 먼저 해야할 일은 아군 플레이어의 일꾼 유닛들로 하여금 자원을 채취하게 하는 것입니다.

각각의 일꾼 유닛에 대해 가장 가까운 Mineral Field 를 지정하여 자원을 채취하게 하면 되겠죠.

  1. 일꾼 유닛마다 각각 다른 Mineral Field 배정하기

각각의 일꾼 유닛에 대해 가장 가까운 Mineral Field 를 지정하면, 게임 맵에 따라 모든 일꾼 유닛들이 하나의 Mineral Field에 몰리는 경우가 발생합니다. 앞서 도착한 일꾼 유닛이 자원을 채취할 때까지는 다른 일꾼 유닛들이 기다리게 되어 지연이 발생하는 것이죠.

각각의 일꾼 유닛에 대해 각각 다른 Mineral Field 를 배정하도록 개발해보겠습니다.

개발 환경 설정

  1. Visual Studio Express 2013 를 실행시킵니다

  2. 메뉴 -> File -> Open Project -> 개발폴더\C\BasicBot.sln 을 선택합니다

  3. Solution Explorer -> TutorialLevel3Bot 우클릭 -> Set as StartUp Project 를 실행합니다

파일 목록

파일명 설명
main.cpp 봇 프로그램의 시작 지점입니다. 스타크래프트 게임과 Connection 을 맺고, MyBotModule을 실행시키고, 스타크래프트 게임이 종료되면 봇 프로그램을 종료시킵니다
Common.h 각종 라이브러리 및 유틸리티를 include 시키는 헤더파일
MyBotModule.h MyBotModule의 구조를 정의하는 헤더파일
MyBotModule.cpp 스타크래프트 게임에서 발생하는 각 이벤트를 GameCommander 가 처리하도록 전달합니다
GameCommander.h GameCommander 의 구조를 정의하는 헤더파일
GameCommander.cpp 각각의 게임 이벤트가 적절하게 처리되도록 해당 객체에게 이벤트를 전달합니다
InformationManager.h InformationManager 의 구조를 정의하는 헤더파일
InformationManager.cpp 게임 상황정보 중 일부를 자체 자료구조 및 변수들에 저장하고 업데이트합니다
WorkerManager.h WorkerManager의 구조를 정의하는 헤더파일
WorkerManager.cpp 일꾼 유닛들의 상태를 관리하고 컨트롤합니다

1. 일꾼으로 자원 채취하기

어떤 일꾼 유닛(workerUnit)으로 하여금 어떤 Mineral Field (mineralUnit)에 대해 자원 채취를 하도록 명령하는 코드는 다음과 같습니다.

	workerUnit->gather(mineralUnit)

혹은 다음과 같이 작성해도 동일합니다. 일반적으로 이 방법이 더 선호됩니다.

	workerUnit->rightClick(mineralUnit);

GameCommander 가 WorkerManager 에게 update()를 지시하고, WorkerManager 가 일꾼에게 자원 채취를 명령하도록 하는 코드는 다음과 같습니다.

(GameCommander.cpp)

void GameCommander::onFrame(){
	...
	// 일꾼 유닛에게 자원 채취를 명령한다
	WorkerManager::Instance().update();
}
(WorkerManager.cpp)

void WorkerManager::update(){
	updateWorkers1();
}

void WorkerManager::updateWorkers1(){

	for (auto & unit : BWAPI::Broodwar->self()->getUnits()){

		if (!unit) continue;
		
		if (unit->getType().isWorker()) {

			// unit으로부터 가장 가까운 Mineral 을 찾아 Right Click 을 한다			
			BWAPI::Unit closestMineral = getClosestMineralFrom(unit);

			if (closestMineral) {
				std::cout << "closestMineral from " 
					<< unit->getType().getName() 
					<< " " << unit->getID() << " is " 
					<< closestMineral->getType().getName() << " " 
					<< closestMineral->getID() << " at " 
					<< closestMineral->getTilePosition().x << "," 
					<< closestMineral->getTilePosition().y 
					<< std::endl;
				
				unit->gather(closestMineral);
			}
		}
	}
}

getClosestMineralFrom() 함수는 간단한 로직으로 작성되어있으므로 설명을 생략하겠습니다.

위 updateWorkers1() 함수는 얼핏 보기에 전혀 문제없는 코드 같아 보이지만, 빌드해서 게임을 실행시켜보면 일꾼 유닛이 MineralField 근처로 이동한 후 꼼짝도 않고 아무 일도 하지 않습니다.

왜 이런 현상이 일어나는 것일까요?

onFrame() 은 매 Frame 마다 (1초에 수십번씩) 발생하는 이벤트이기 때문에, 위 코드는 일꾼 유닛으로 하여금 MineralField에 대해 계속 반복적으로 gather 명령을 내리는 코드가 됩니다. 명령을 내리면 명령 실행을 시작해서 명령 완수 되기까지 시간이 필요한데, 명령 실행을 시작했다가 새 명령을 받아 다시 명령 실행을 시작하는 것을 반복하다보니 결국 아무 일도 못하게 된 것입니다.

이를 해결하기 위해서는, 유닛이 idle 상태인지 체크해서 idle 상태일 때 딱 1번만 명령을 내려야 합니다.

다음과 isIdle() 로 상태 체크를 하는 코드를 넣어 수정한 후 실행시켜봅시다. 이제 일꾼 유닛들이 정상적으로 자원을 채취하기 시작할 것입니다.

		...
		if (closestMineral) {
			if (unit->isIdle()) {
				std::cout << "closestMineral from " 
					<< unit->getType().getName() 
					<< " " << unit->getID() << " is " 
					<< closestMineral->getType().getName() << " " 
					<< closestMineral->getID() << " at " 
					<< closestMineral->getTilePosition().x << "," 
					<< closestMineral->getTilePosition().y 
					<< std::endl;
				unit->gather(closestMineral);
			}
		}
		...

이 상태에서 신규로 일꾼 유닛을 훈련시켜 볼까요?

Protoss 및 Terran 종족의 경우 일꾼 유닛 훈련이 시작되자마자 명령 프롬프트에 로그가 쉴새없이 출력되며 게임이 느려지는 현상이 나타날 것입니다.

왜 이런 현상이 일어나는 것일까요?

Protoss 및 Terran 종족의 경우 일꾼 유닛 훈련이 시작되면 그 즉시 일꾼 유닛이 Create 되고 일꾼 유닛이 idle 인 상태에서 유닛 훈련을 진행하기 때문입니다. (Zerg 종족의 경우에는 Larva가 Egg로 Morph 한 후 일정 시간이 지났을 때 일꾼 유닛으로 Morph 하므로, Zerg 종족은 이런 현상이 일어나지 않습니다)

일꾼 유닛 훈련이 Complete 되어야 일꾼 유닛이 실제로 게임 화면에 출현하게 되고 명령을 내릴 수 있는 상태가 되는데, 이런 상태 체크가 누락되었기 때문에 아직 출현하지도 않은 유닛에 대해 계속 명령을 내리는 코드를 실행시키는 것입니다.

이를 해결하기 위해서는, 유닛이 idle 이면서 동시에 isCompleted 인지 체크를 해서 명령을 내려야 합니다.

이처럼 어떤 명령을 내릴 때, 유닛의 상태를 꼼꼼히 체크해야 완전한 코드가 된다는 것을 명심하시길 바랍니다. 사람이 게임을 플레이할 때는 깊이 생각하지 않아도 당연히 아는 것이지만, 인공지능이 게임을 플레이하게 할 때는 이런 것을 꼼꼼히 체크해야 하는 것이죠

제대로 수정한 updateWorkers2 함수는 다음과 같습니다

(WorkerManager.cpp)

void WorkerManager::update(){

	//updateWorkers1();

	updateWorkers2();
}

void WorkerManager::updateWorkers2(){

	for (auto & unit : BWAPI::Broodwar->self()->getUnits()){

		if (!unit) continue;
		
		if (unit->getType().isWorker()) {

			// unit 이 idle 상태이고, 탄생한 이후이면
			if (unit->isIdle() && unit->isCompleted()) {

				// unit으로부터 가장 가까운 Mineral 을 찾아 Right Click 을 한다
				BWAPI::Unit closestMineral = getClosestMineralFrom(unit);

				if (closestMineral) {
					std::cout << "closestMineral from " 
						<< unit->getType().getName() 
						<< " " << unit->getID() << " is " 
						<< closestMineral->getType().getName() << " " 
						<< closestMineral->getID() << " at " 
						<< closestMineral->getTilePosition().x << "," 
						<< closestMineral->getTilePosition().y 
						<< std::endl;
				
					unit->gather(closestMineral);
				}
			}
		}
	}
}

2. 일꾼 유닛마다 각각 다른 Mineral Field 배정하기

각각의 일꾼 유닛에 대해 가장 가까운 Mineral Field 를 지정하면, 게임 맵에 따라 모든 일꾼 유닛들이 하나의 Mineral Field에 몰리는 경우가 발생합니다. 앞서 도착한 일꾼 유닛이 자원을 채취할 때까지는 다른 일꾼 유닛들이 기다리게 되어 지연이 발생하는 것이죠.

일꾼 유닛이 가장 적게 배정된 Mineral Field 들을 찾고 그중에서 가장 가까운 Mineral Field 를 지정하여 자원을 채취하게 하면 이런 지연이 발생하지 않을 것입니다. 이런 것을 개발 안해도 그럭저럭 게임은 작동하겠지만, 더 효율적인 알고리즘을 생각해보고 개발해보는 것이 알고리즘 경진대회의 목적인 만큼, 자료구조를 사용해서 개발해보도록 하겠습니다.

먼저 WorkerManager 의 멤버변수로 자료구조를 선언하고 초기화하겠습니다. Mineral Field 마다 몇명의 일꾼 유닛이 배정되었는지를 저장하고 관리하는 Map 형태의 자료구조 workerCountOnMineral 를 선언하겠습니다. Worker와 Mineral Field 간 배정 관계를 저장하는 Map 형태의 자료구조 로 개발을 해보겠습니다. 일꾼 유닛별로 배정된 Mineral Field 를 저장하는 workerMineralAssignment 도 당장은 불필요해보이지만 일단 선언하겠습니다.

(WorkerManager.h)

class WorkerManager
{
	// 각각의 Mineral Field 에 assign 된 Worker 숫자 를 저장하는 map
	std::map<BWAPI::Unit, int> workerCountOnMineral;

	// Worker ~ Mineral Field 간 assign 관계를 저장하는 map
	std::map<BWAPI::Unit, BWAPI::Unit> workerMineralAssignment;
	...
}
(WorkerManager.cpp)

WorkerManager::WorkerManager()
{
	for (auto & unit : BWAPI::Broodwar->getAllUnits())
	{
		if ((unit->getType() == BWAPI::UnitTypes::Resource_Mineral_Field))
		{
			workerCountOnMineral[unit] = 0;
		}
	}
}

이제 이 자료구조를 사용하여 일꾼 유닛에게 최적의 Mineral Field 를 찾아 지정하도록 하겠습니다. 소스코드가 조금 길지만 로직이 간단하므로 보시는데 큰 어려움은 없을 것입니다.

(WorkerManager.cpp)

void WorkerManager::update() 
{
	//updateWorkers1();
	
	//updateWorkers2();	

	updateWorkers3();
}
void WorkerManager::updateWorkers3()
{
	for (auto & unit : BWAPI::Broodwar->self()->getUnits()){

		if (!unit) continue;
		
		if (unit->getType().isWorker()) {

			if (unit->isIdle() && unit->isCompleted()) {

				BWAPI::Unit bestMineral = getBestMineralTo(unit);

				if (bestMineral) {
					unit->gather(bestMineral);

					// unit 과 Mineral 간 assign 정보를 업데이트한다
					workerMineralAssignment[unit] = bestMineral;
					// Mineral 별 assigned unit 숫자를 업데이트한다
					increaseWorkerCountOnMineral(bestMineral, 1);
				}
			}
		}
	}

	// Mineral 별 assigned unit 숫자를 화면에 표시한다
	for (auto & i : workerMineralAssignment) {
		if (i.first != nullptr && i.second != nullptr) {
			BWAPI::Unit mineral = i.second;
			if (workerCountOnMineral.find(mineral) != workerCountOnMineral.end()) {
				BWAPI::Broodwar->drawTextMap(
					mineral->getPosition().x, mineral->getPosition().y + 12, 
					"worker: %d", workerCountOnMineral[mineral]);
			}
		}
	}
}


BWAPI::Unit WorkerManager::getBestMineralTo(BWAPI::Unit worker)
{
	if (!worker) return nullptr;

	// worker으로부터 가장 가까운 BaseLocation을 찾는다
	BWTA::BaseLocation * closestBaseLocation = nullptr;
	double closestDistance = 1000000000;

	for (auto & baseLocation : BWTA::getBaseLocations()){
		if (!baseLocation) continue;

		double distance = worker->getDistance(baseLocation->getPosition());

		if (distance < closestDistance)
		{
			closestBaseLocation = baseLocation;
			closestDistance = distance;
		}
	}

	if (!closestBaseLocation) {
		return nullptr;
	}

	// 해당 BaseLocation 의 Mineral 들 중에서 worker 가 가장 적게 지정되어있는 것, 그중에서도 BaseLocation 으로부터 가장 가까운 것을 찾는다
	BWAPI::Unit bestMineral = nullptr;
	double bestDistance = 1000000000;
	int bestNumAssigned = 1000000000;

	for (auto & mineral : closestBaseLocation->getMinerals()){
		if (!mineral) continue;

		// 해당 Mineral 에 지정된 worker 숫자
		int numAssigned = workerCountOnMineral.find(mineral) == workerCountOnMineral.end() ? 0 : workerCountOnMineral[mineral];
		// 해당 Mineral 과 BaseLocation 간의 거리
		double dist = mineral->getDistance(closestBaseLocation->getPosition());

		if (numAssigned < bestNumAssigned)
		{
			bestMineral = mineral;
			bestDistance = dist;
			bestNumAssigned = numAssigned;
		}
		else if (numAssigned == bestNumAssigned)
		{
			if (dist < bestDistance)
			{
				bestMineral = mineral;
				bestDistance = dist;
				bestNumAssigned = numAssigned;
			}
		}
	}

	return bestMineral;
}

void WorkerManager::increaseWorkerCountOnMineral(BWAPI::Unit mineral, int num)
{
	// Mineral 에 assign 된 worker 숫자를 변경한다
	if (workerCountOnMineral.find(mineral) == workerCountOnMineral.end()) {
		workerCountOnMineral[mineral] = num;
	}
	else {
		workerCountOnMineral[mineral] = workerCountOnMineral[mineral] + num;
	}
}

여기까지만 구현한 후 TutorialLevel3Bot 을 실행시키고 대결을 시켜보면, 게임 시작 시 각각의 일꾼 유닛들이 서로 다른 Mineral Field 에 배정되어 정상적으로 작동하는 것처럼 보입니다. 일꾼 유닛이 추가됨에 따라 workerCountOnMineral 도 하나씩 증가하며 잘 작동합니다.

그러나, 문제는 일꾼 유닛이 죽거나 Morph 하여 없어질 경우 발생합니다. 초반에 적군의 공격을 받아 일꾼 유닛들이 상당수 죽는 상황이 발생해도, workerCountOnMineral 의 숫자를 감소시키는 로직이 없으므로 이후 신규 생성된 일꾼 유닛에 대한 Mineral Field 배정에 문제가 발생합니다.

이를 해결하기 위해 일꾼 유닛이 죽거나 Morph 하는 경우에 대해 처리하는 코드는 다음과 같습니다. onFrame() 이벤트가 아니라 onUnitDestroy() 및 onMorph() 이벤트가 발생했을 때 workerMineralAssignment를 검색하여 적절하게 workerCountOnMineral 의 숫자를 감소시키는 것이지요.

(GameCommander.cpp)

void GameCommander::onUnitDestroy(BWAPI::Unit unit)		
{
	WorkerManager::Instance().onUnitDestroy(unit);
}

void GameCommander::onUnitMorph(BWAPI::Unit unit)
{ 
	WorkerManager::Instance().onUnitMorph(unit);
}
(WorkerManager.cpp)

void WorkerManager::onUnitDestroy(BWAPI::Unit unit)
{
	if (!unit) return;

	if (unit->getType().isWorker() && unit->getPlayer() == BWAPI::Broodwar->self()) 
	{
		// 해당 일꾼과 Mineral 간 assign 정보를 삭제한다
		increaseWorkerCountOnMineral(workerMineralAssignment[unit], -1);
		workerMineralAssignment.erase(unit);
	}
}

void WorkerManager::onUnitMorph(BWAPI::Unit unit)
{
	if (!unit) return;

	// 저그 종족 일꾼이 건물로 morph 한 경우
	if (unit->getPlayer()->getRace() == BWAPI::Races::Zerg && unit->getPlayer() == BWAPI::Broodwar->self() && unit->getType().isBuilding())
	{
		// 해당 일꾼과 Mineral 간 assign 정보를 삭제한다
		increaseWorkerCountOnMineral(workerMineralAssignment[unit], -1);
		workerMineralAssignment.erase(unit);
	}
}

한번 저그 종족으로 게임을 실행하여 일꾼 유닛을 죽이거나 건물로 Morph 시켜보세요. 이제는 일꾼 유닛이 죽거나 Morph 할 때, Mineral Field 에 배정된 일꾼 유닛 숫자가 감소하고, 신규 일꾼 유닛에게는 적절한 Mineral Field 가 배정될 것입니다.

아군 유닛이 기습 공격을 받아 유닛들이 몰살 당하거나, 중요 건물이 파괴되어 Tech Tree 가 끊기고 특정 유닛 생산이 중단되는등 온갖 다양한 경우에 대해서도 적절하게 대처하도록 해놓아야 튼튼한 (Robust) 봇 프로그램이 됨을 알 수 있습니다.