본문 바로가기

Programming Language/C, C++

[Snake Game] 2. 키보드로 플레이어 움직이기

2. 키보드로 플레이어 움직이기


Intro

보통 키보드로 입력을 받을 때는 scanf 함수를 사용하지만, 게임의 조작키를 입력받을 경우에는 여기서 다룰 kbhit()getch()를 사용한다. 우리가 입력받을 키는 키보드의 상하좌우키 이다. WASD를 사용하고 싶다면 그에 맞게 적절히 고치면 된다. 입력을 받을 때 알아두어야 할 것은 입력받을 값의 ASCII 코드 값이다. 아래의 표를 잘 살펴보자.



위 아스키코드 표는 IBM Knowledge Center에서 가져왔다. 그런데 자세히 살펴보아도 방향키에 대한 코드가 보이지 않는다. 그래서 구글 검색을 통해 상:72, 하:80, 좌:75, 우:77임을 알아내었다. 왜 그런지는 여기를 참고하도록 하자.


일단 맵 출력하는 코드가 보기에 좋지 않기 때문에, 별도의 함수로 빼내도록 하자. 다음과 같이 printMapBoundary함수를 만들자.

#include <stdio.h>
#include <Windows.h>

// ■ ▲ ▼ ◀ ▶ ♥
// map = (30*2) * (27*2)

void printMapBoundary(); // main 함수 위에서 선언

void setCursorPos(int x, int y) // 콘솔 좌표 위치 지정
{
...
}

int main()
{
	printMapBoundary();
	setCursorPos(30, 13);
	printf("■");
	setCursorPos(30, 14);
	printf("■");
	setCursorPos(30, 15);
	printf("■");
	return 0;
}
void printMapBoundary() // 함수 내용 정의
{
	printf("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
...
	printf("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n");
}


이제 kbhit()과 getch()를 이용해 키보드의 입력값을 가져오자. kbhit()은 키보드가 눌리는 것을 감지한다. getch()는 입력받은 키의 아스키코드값을 가져온다. 우리가 구현해야 하는 동작은 아래와 같다.

1. 키보드 입력이 감지되면 -> 2. 아스키 코드 값을 가져오고 -> 3. 입력받은 키에 해당하는 동작을 수행한다.

if문과 kbhit()을 이용해 키보드 입력을 감지하고, getch()로 아스키코드 값을 가져온 후, switch~case를 이용해 동작을 정의하면 된다.


#include <stdio.h>
#include <Windows.h>
#include <conio.h>

...

int main()
{
	printMapBoundary();
	setCursorPos(30, 13);
	printf("■");

	if (kbhit()) { // 1. 키보드 입력이 감지되면
		int pressedKey = getch(); // 2. 아스키 코드 값을 가져오고
		switch (pressedKey) { // 3. 입력받은 키에 해당하는 동작을 수행한다.
		case 72:
			// 위로 이동
		case 80:
			// 아래로 이동
		case 75:
			// 왼쪽으로 이동
		case 77:
			// 오른쪽으로 이동
		}
	}
	return 0;
}
...


kbhit()은 <conio.h>에 정의되어 있기 때문에 conio.h를 include한다. kbhit()은 키 입력이 감지되면 1을 반환한다.

이제 이동을 구현하여야 한다. 콘솔창에서 이동을 표현하려면, 이동한 위치에 다시 출력해주어야 한다. 그렇기 때문에 현재 좌표 정보를 저장하는 변수가 필요하다. 이를 각각 currentPosX, currentPosY라고 하자.


int currentPosX;
int currentPosY;
...
currentPosX = 30;
currentPosY = 13;

if (kbhit()) {
		int pressedKey = getch();
		switch (pressedKey) {
		case 72:
			// 위로 이동
			currentPosY -= 1;
			setCursorPos(currentPosX, currentPosY);
			printf("■");
			break;

		case 80:
			// 아래로 이동
			currentPosY += 1;
			setCursorPos(currentPosX, currentPosY);
			printf("■");
			break;

		case 75:
			// 왼쪽으로 이동
			currentPosX -= 2;
			setCursorPos(currentPosX, currentPosY);
			printf("■");
			break;

		case 77:
			// 오른쪽으로 이동
			currentPosX += 2;
			setCursorPos(currentPosX, currentPosY);
			printf("■");
			break;

		default:
			break;
		}
	}

이 코드를 빌드한 후 실행해보자. 만약 kbhit()이 deprecated 되었다는 내용의 빌드 오류가 발생하면 헤더파일 인클루드 한 곳의 바로 밑에 아래 코드를 집어넣자.

#pragma warning(disable:4996)


실행해보면 화면이 나타나고 바로 프로그램이 종료될 것이다. 이는 kbhit()이 단순히 키보드가 입력 되었는지만을 한 번만 감지하는 함수이기 때문이다. 이를 해결하기 위해 무한히 반복문을 돌려 매 시간 감지하도록 만들어야 한다.

int main(){
    	...
    	while (true) {
	    if (kbhit()) {
	    	int pressedKey = getch();
	    	switch (pressedKey) {
	        case 72:// 위로 이동
                currentPosY -= 1;
                setCursorPos(currentPosX, currentPosY);
	    		printf("■");
	    		break;  

	    	case 80:
	     		// 아래로 이동
    			currentPosY += 1;
    			setCursorPos(currentPosX, currentPosY);
    			printf("■");
    			break;      
    		...   
    		} 
    	}
    	... 
}


이를 실행해보면 입력받은 대로 움직이는 것을 확인할 수 있다.



그런데 잔상이 남는다. 이동하기 전에 출력된 내용이 그대로 남아있기 때문이다. 이를 지우는 것 까지 해야 진짜 움직임이 구현되었다고 할 수 있다. 지우는 것은 간단하다. 원래 있던 좌표에 공백을 두 번 출력해주면 된다.


...			
				case 72:
				// 위로 이동
				setCursorPos(currentPosX, currentPosY);
				printf("  ");
				currentPosY -= 1;
				setCursorPos(currentPosX, currentPosY);
				printf("■");
				break;

			case 80:
				// 아래로 이동
				setCursorPos(currentPosX, currentPosY);
				printf("  ");
				currentPosY += 1;
				setCursorPos(currentPosX, currentPosY);
				printf("■");
				break;

			case 75:
				// 왼쪽으로 이동
				setCursorPos(currentPosX, currentPosY);
				printf("  ");
				currentPosX -= 2;
				setCursorPos(currentPosX, currentPosY);
				printf("■");
				break;

			case 77:
				// 오른쪽으로 이동
				setCursorPos(currentPosX, currentPosY);
				printf("  ");
				currentPosX += 2;
				setCursorPos(currentPosX, currentPosY);
				printf("■");
				break;

			default:
				break;
			}
...


아래처럼 잘 움직이는 것을 확인할 수 있다.


그런데, 뱀 게임의 플레이어 캐릭터는 입력된 방향으로 계속 움직여야 한다. 아래의 코드는 그것을 구현한 것이다. 아마 뱀게임을 코딩하는 과정에서 가장 어려운 부분일 것이다. 코드와 원리를 잘 이해하길 바란다.


우선, 지금까지는 switch~case문 내부에서 캐릭터의 움직임을 표현하였다. 그런데, switch~case문은 kbhit()이 1을 반환할 때, 즉 키보드가 눌릴때만 동작하게 된다.(왜냐하면 switch~case문이 if( kbhit() ){ }의 내부에 있기 때문이다) 하지만 우리가 원하는 것은 키보드가 눌리지 않았을 때에도 뱀이 계속 움직이는 것이다.

그래서, 움직임을 구현하는 부분을 if문 밖으로 빼내야 한다. 그리고 switch~case에서는 뱀의 이동 방향만 설정하도록 해야 한다. 이를 구현한 것이 아래의 코드이다.

#include <stdio.h>
#include <Windows.h>
#include <conio.h>

#pragma warning(disable:4996)
// ■ ▲ ▼ ◀ ▶ ♥
// map = (30*2) * (27*2)

int headDirectionWeight[4][2] = {
	{0, -1}, // 위로 이동
	{0, 1}, // 아래로 이동
	{-2, 0}, // 왼쪽으로 이동
	{2, 0} // 오른쪽으로 이동
};

void printMapBoundary();
int setHeadDirection(int pressedKeyData);
void moveSnakeHead(int &posX, int &posY, int headDirection);

void setCursorPos(int x, int y) // 콘솔 좌표 위치 지정
{
	...
}

int main()
{
	int currentPosX;
	int currentPosY;
	int currentHeadDirection = 0;
	printMapBoundary();
	setCursorPos(30, 13);
	printf("■");
	currentPosX = 30;
	currentPosY = 13;
	while (true) {
		if (kbhit()) {
			if (getch() == 224) {
				int pressedKey = getch();
				currentHeadDirection = setHeadDirection(pressedKey);
			}
		}
		moveSnakeHead(currentPosX, currentPosY, currentHeadDirection);
		Sleep(100);
	}
	return 0;
}

void moveSnakeHead(int &posX, int &posY, int headDirection) {
	setCursorPos(posX, posY);
	printf("  ");
	posX += headDirectionWeight[headDirection][0];
	posY += headDirectionWeight[headDirection][1];
	setCursorPos(posX, posY);
	printf("■");
}

int setHeadDirection(int pressedKeyData) {
	int changedHeadDirection;
	switch (pressedKeyData) {
	case 72:
		// 진행 방향을 위로 지정
		changedHeadDirection = 0;
		break;

	case 80:
		// 진행 방향을 아래로 지정
		changedHeadDirection = 1;
		break;

	case 75:
		// 진행 방향을 왼쪽로 지정
		changedHeadDirection = 2;
		break;

	case 77:
		// 진행 방향을 오른쪽로 지정
		changedHeadDirection = 3;
		break;

	default:
		break;
	}
	return changedHeadDirection;
}

void printMapBoundary()
{
...
}


뱀의 이동 방향을 설정하는 switch~case문이 포함된 setHeadDirection함수, 뱀의 이동을 구현하는 moveSnakeHead함수가 추가되었으며, 뱀의 이동 방향으로 뱀을 이동시키기 위한 가중치값을 모아놓은 배열인 headDirectionWeight라는 가중치 배열이 추가되었다.

int currentHeadDirection = 0;	
while (true) {
	if (kbhit()) {
		if (getch() == 224) {
			int pressedKey = getch();
			currentHeadDirection = setHeadDirection(pressedKey);
		}
	}
	moveSnakeHead(currentPosX, currentPosY, currentHeadDirection);
	Sleep(100);
}


위 반복문의 내부를 잘 살펴보자. 무한히 반복하면서, 키보드 입력이 감지되었을 때, 감지된 입력값이 224이면 한번 더 버퍼에 저장된 값을 가져오고 그것을 setHeadDirection 함수로 넘겨준다. 

키보드로 방향키 값을 입력했을 때, 방향키의 값이 224라는 값과 함께 넘겨지는데, 이는 해당 키가 알파벳 키가 아닌 방향키임을 나타내기 위한 컴퓨터 내부의 약속에 따른 것이다. 즉, 위쪽 키가 눌리면 224, 72가 넘겨지고, 아래쪽 키가 눌리면 224, 80이 넘겨진다. 자세한 내용은 여기를 참고하도록 하자.


kbhit()이 0을 반환하면, 다시 말해 키보드 입력이 없으면, 머리의 이동 방향을 바꾸는 함수인 setHeadDirection이 호출되지 않고 바로 뱀을 이동시키는 함수인 moveSnakeHead 함수가 호출된다.

kbhit()이 1을 반환하면, 다시 말해 키보드 입력이 있으면, 입력값을 확인하고, 확인된 값이 224일 경우, 그 뒤에 오는 입력값을 받아 머리의 이동 방향을 바꾸는 함수인 setHeadDirection에 넘겨준다. 왜 224인지는 여기를 참고하라.


머리의 이동 방향을 바꾸는 함수인 setHeadDirection 함수는 아래와 같다.

int setHeadDirection(int pressedKeyData) {
	int changedHeadDirection;
	switch (pressedKeyData) {
	case 72:
		// 진행 방향을 위로 지정
		changedHeadDirection = 0;
		break;

	case 80:
		// 진행 방향을 아래로 지정
		changedHeadDirection = 1;
		break;

	case 75:
		// 진행 방향을 왼쪽로 지정
		changedHeadDirection = 2;
		break;

	case 77:
		// 진행 방향을 오른쪽로 지정
		changedHeadDirection = 3;
		break;

	default:
		break;
	}
	return changedHeadDirection;
}


setHeadDirection 함수는 눌려진 키의 값을 받아, 각각 0, 1, 2, 3의 값을 반환한다. 이것은 headDirectionWeight라는 가중치 배열을 이용하기 위함이다. headDirectionWeight의 값은 아래와 같다.

int headDirectionWeight[4][2] = {
	{0, -1}, // 위로 이동
	{0, 1}, // 아래로 이동
	{-2, 0}, // 왼쪽으로 이동
	{2, 0} // 오른쪽으로 이동
};


오른쪽 방향키가 입력되었을 때, setHeadDirection에서는 3이 반환될 것이다. 이때 뱀의 이동에 적용될 가중치값은 headDirectionWeight[3]의 값으로, 각각 2와 0이다. 2는 x축 방향으로 2칸 더 간다는 것을, 0은 y축 방향으로 0칸 더 간다는 것을 의미한다. x축 방향으로만 2칸 더 가기 때문에 결국 콘솔창에서 ■이 오른쪽으로 한 칸 이동하게 된다.(■이 콘솔창의 2칸을 차지한다는 것을 언제나 염두해두자)
headDirectionWeight의 값을 이용해 뱀을 이동시키는 함수인 moveSnakeHead 함수를 살펴보자.

void moveSnakeHead(int &posX, int &posY, int headDirection) {
	setCursorPos(posX, posY);
	printf("  ");
	posX += headDirectionWeight[headDirection][0];
	posY += headDirectionWeight[headDirection][1];
	setCursorPos(posX, posY);
	printf("■");
}


함수의 인자에 포인터를 넘겨주면서 본래 변수의 값도 바뀌도록 하였다. moveSnakeHead 함수가 하는 일은, 현재 자리에 있는 ■을 지우고 headDirection에 해당하는 방향으로 좌표를 옮긴 후, 해당 좌표에 ■를 그리는 것이다. 이전에 switch~case에서 이동을 구현했을 때에는 이동 방향에 따라 위아래 이동시에는 y값만, 좌우 이동시에는 x값만 계산해주었다. 하지만 이제 이동 동작과 이동 방향 설정이 분리되었기 때문에, 어느 방향으로 이동할지 예측을 할 수 없기 때문에 x와 y값 모두 계산해주는 것이다.


위 내용을 종합하여, 반복문 내부의 동작 순서를 정리하면 아래와 같다.

1. 반복문 시작

2. 키보드 입력이 감지 되었는가?

2-1. 버퍼에 있는 값을 하나 더 가져옴

2-2. 값에 해당하는 방향으로 이동 방향을 설정

3. 이동

키보드 입력이 감지되지 않았다면, 앞서 저장된 이동 방향에 따라 이동하는 함수가 실행되므로, 우리가 원하는 것 처럼 계속해서 움직이는 플레이어 캐릭터가 구현된다. 여기까지 잘 따라왔으면 스스로를 칭찬하여도 좋다.


하지만 이 코드에서도 여전히 문제는 발생한다. 아래의 문제를 해결하여야 한다.

1. 좌표에 제한이 없기 때문에 어디든 갈 수 있다.

2. sleep()함수가 가지고 있는 문제점이 그대로 나타난다.

첫 번째 문제부터 고쳐보자.

우선, 좌표에 제한이 없기 때문에 캐릭터가 어디든 갈 수 있다. 아래 그림을 참고하자.


벽을 뚫고 나갔다. 이를 해결하기 위해, 이동할 수 있는 좌표에 제한을 두어야 한다. 캐릭터가 이동할 수 있는 좌표의 범위는 맵의 테두리의 내부에 해당한다. 간단하게 생각해보면, 좌우로는 2이상 56이하, 상하로는 1이상 27이하이다. 이를 moveSnakeHead 함수에 반영하여 고치면 아래와 같다,

void moveSnakeHead(int &posX, int &posY, int headDirection) {
	int prePosX = posX + headDirectionWeight[headDirection][0];
	int prePosY = posY + headDirectionWeight[headDirection][1];
	if (prePosX < 2 || prePosX>56)return;
	if (prePosY < 1 || prePosY>27)return;
	setCursorPos(posX, posY);
	printf("  ");
	posX = prePosX;
	posY = prePosY;
	setCursorPos(posX, posY);
	printf("■");
}


미리 좌표를 계산해(prePosX, prePosY) 그 좌표가 허용 범위 밖이면 함수를 끝내도록 하였다. 허용 범위 안이면 실제로 적용해 이동 작업을 실시한다. 아래 그림처럼 벽 내부에서만 돌아다니는 것을 확인할 수 있다.


이제 두 번째 문제를 고쳐야 한다. Sleep 함수는 프로그램의 동작을 얼마간 멈추는 함수이다. 위의 코드에서는 sleep 함수를 적용했기 때문에 캐릭터의 움직임을 눈으로 볼 수 있다. sleep 함수가 없으면, 컴퓨터의 가공할만한 정보처리능력에 의해 캐릭터는 순식간에 벽에 붙어버릴 것이다. 


하지만, sleep 함수는 자체로 게임에 사용하기에 부족한 점이 하나 있는데, 그것은 sleep함수가 진행되는 동안 프로그램이 완전히 lock 되어버린다는 것이다. 그 시간 동안 프로그램은 정말 잠을 자는 것 처럼 모든 동작을 잠정 중단, 보류한다.


위 코드의 sleep 함수에 인자로 10000을 넘겨주고 sleep 함수가 진행되는 동안 왼쪽 키를 누른 다음 빠르게 오른쪽 키를 눌러 보자. 우리가 원하는 것은 마지막 명령인 오른쪽 방향으로 가는 것이다. 하지만, sleep함수가 진행되는 동안 프로그램은 모든 동작을(입력 마저도)보류하기 때문에 sleep이 끝난 후, 버퍼에 저장된 순서에 따라 왼쪽 방향으로 갈 것이다. 


이런 점 때문에 sleep함수를 사용할 경우 소위 '키가 먹힌다'거나 '키가 물린다' 혹은 '키가 묻힌다'로 표현되는 컨트롤 미스가 발생할 수 있다.

이것을 해결하기 위해, 우리는 delay 시간 동안에도 계속해서 setHeadDirection 작업을 할 수 있는 우리만의 sleep 기능을 만들어야 한다. 


이 때 사용할 수 있는 것이 바로 time.h에 정의되어 있는 clock() 함수이다.

사족으로, c와 c++에는 Sleep(), sleep(), _sleep()이라는 함수가 각각 존재한다만, 우리가 크게 신경 쓸 부분은 아니다.


※ clock() 함수를 이용한 sleep 기능 구현

clock() 함수는 프로그램이 시작한 시점부터 현재까지 경과한 시간을 반환하는 함수이다. 어느 시점의 clock 값을 저장한 후 현재의 clock 값과 비교하면 그 시점과 현재 사이의 시차를 구할 수 있다. 이 값과 반복문을 이용해 delay를 구현하고, 반복문 내부에서 계속해서 입력과 setHeadDirection을 하도록 하면, 뱀이 잠깐 멈춰있는 그 찰나에도 뱀을 컨트롤 할 수 있어 미세 컨트롤이 가능한 게임을 만들 수 있다.

그 코드는 아래와 같다.

while (true) {
	int refTime = clock();
	while (true) {
		if (kbhit()) {
			if (getch() == 224) {
				int pressedKey = getch();
				currentHeadDirection = setHeadDirection(pressedKey);
			}
		}
		int currentTime = clock()-refTime;
		if (currentTime > 200)break;
	}
	moveSnakeHead(currentPosX, currentPosY, currentHeadDirection);
}

이렇게 하면 시간을 보내면서도 계속 입력을 받을 수 있다.


다음에는 지금까지 만든 코드를 좀 더 예쁘게 다듬어 볼 것이다. 여기까지 왔으면 뱀게임의 반 이상을 만든 것과 다름이 없다. 아래는 지금까지 작성한 코드이다.


#include <stdio.h>
#include <Windows.h>
#include <conio.h>
#include <time.h>

#pragma warning(disable:4996)
// ■ ▲ ▼ ◀ ▶ ♥
// map = (30*2) * (27*2)

int headDirectionWeight[4][2] = {
	{0, -1}, // 위로 이동
	{0, 1}, // 아래로 이동
	{-2, 0}, // 왼쪽으로 이동
	{2, 0} // 오른쪽으로 이동
};

void printMapBoundary();
int setHeadDirection(int pressedKeyData);
void moveSnakeHead(int &posX, int &posY, int headDirection);

void setCursorPos(int x, int y) // 콘솔 좌표 위치 지정
{
	COORD pos = { x,y };
	SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos);
}

int main()
{
	int currentPosX;
	int currentPosY;
	int currentHeadDirection = 0;
	printMapBoundary();
	setCursorPos(30, 13);
	printf("■");
	currentPosX = 30;
	currentPosY = 13;
	while (true) {
		int refTime = clock();
		int currentTime=0;
		while (true) {
			if (kbhit()) {
				if (getch() == 224) {
					int pressedKey = getch();
					currentHeadDirection = setHeadDirection(pressedKey);
				}
			}
			currentTime += clock()-refTime;
			if (currentTime > 300000) {
				currentTime = 0;
				break;
			}
		}
		moveSnakeHead(currentPosX, currentPosY, currentHeadDirection);
	}
	return 0;
}

void moveSnakeHead(int &posX, int &posY, int headDirection) {
	int prePosX = posX + headDirectionWeight[headDirection][0];
	int prePosY = posY + headDirectionWeight[headDirection][1];
	if (prePosX < 2 || prePosX>56)return;
	if (prePosY < 1 || prePosY>27)return;
	setCursorPos(posX, posY);
	printf("  ");
	posX = prePosX;
	posY = prePosY;
	setCursorPos(posX, posY);
	printf("■");
}

int setHeadDirection(int pressedKeyData) {
	int changedHeadDirection;
	switch (pressedKeyData) {
	case 72:
		// 진행 방향을 위로 지정
		changedHeadDirection = 0;
		break;

	case 80:
		// 진행 방향을 아래로 지정
		changedHeadDirection = 1;
		break;

	case 75:
		// 진행 방향을 왼쪽로 지정
		changedHeadDirection = 2;
		break;

	case 77:
		// 진행 방향을 오른쪽로 지정
		changedHeadDirection = 3;
		break;

	default:
		break;
	}
	return changedHeadDirection;
}

void printMapBoundary()
{
	printf("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■                                                        ■\n");
	printf("■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n");
}