본문 바로가기
C

Native C로 리듬게임 만들기 #1

by 수표 2020. 1. 27.

내가 다니는 학교 한국디지털미디어 고등학교에서는 1학년때 C언어 프로그래밍 교과를 배운다.

C를 미리 알고 있다면 공부를 하지 않고서 A를 받는 꿀 과목이지만, 이미 C언어를 알고 있는 사람은 교과우수상을 노리지 A로는 성이 차지 않는다.

 

그래서 교과우수상을 노리기 위한 게임만들기 프로젝트

이름하야 '리듬린민 생성기' (프로젝트 제출하기 3초전에 생각해낸 이름) 프로젝트!

 

먼저 네이티브 C는 굉장히 거지같다. 안되는게 많고 필요한건 직접 만들어서 써야 하기 때문이다.

이러한 단점을 극복하기 위한 방법은 구글 찬스와 친구 찬스밖에 없는것 같다..

 

먼저 리듬게임을 만들려면 맵 파일을 불러와야 노트를 만들던지 할 것 이다.

Osu! 게임의 맵 파일이다.

내가 선택한 방법은 Osu! 라는 리듬게임의 맵 파일을 불러오는 것 이였다.

 

노트를 불러오는 부분이다.

이렇게 Osu 파일의 HitObjects 세션을 보면 노트패턴이 이렇게 나와있다.

자세한 구조는 아래 문서를 참조하면 알 수 있다.

https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)

 

osu! File Formats / .osu (file format) · 위키 · 도움말 | osu!

.osu is a human-readable file format containing information about a beatmap. The first line of the file specifies the file format version. For example, osu file format v14 is the latest version. The following content is separated into sections, indicated b

osu.ppy.sh

int TimingPoints[MAX_TSTAMP][M_ROW] = { 0 };
int ImagePoints[MAX_TSTAMP][M_ROW] = { 0 };

void TPoint(char* TStr)
{
	int row[6] = { 0 };
	char* ptr = strtok(TStr, ",");
	char strs[200];
	int i = 0, key = 0;
	while (ptr != NULL)               // 자른 문자열이 나오지 않을 때까지 반복
	{
		Trim(ptr, strs);			  // 좌우 빈칸 제거
		row[i] = atoi(strs);          // 값 추가
		ptr = strtok(NULL, ",");      // 다음 문자열을 잘라서 포인터를 반환
		i++;
	}

	switch (row[0])
	{
		case 64:
			key = 0;
			break;
		case 192:
			key = 1;
			break;
		case 320:
			key = 2;
			break;
		case 448:
			key = 3;
			break;
	}
	TimingPoints[row[2]][key] = 1;
	if (row[3] == 128) {            // 롱노트 일 경우
		for(int n = row[2]; n <= row[5]; n++)
			ImagePoints[n][key] = 1;
	}else {
		ImagePoints[row[2]][key] = 1;
	}
}

먼저 HitObject 부분만 불러와서 한줄씩 TStr에 넣어주면 자동으로 등록된다.

 

TimingPoints 와 ImagePoints가 따로 나와있는데, TimingPoints는 실제 우리가 치는 노트의 위치이고, ImagePoints는 화면에 나올 부분이다.

이를 나누어 놓은 이유는 롱노트의 경우 시작부분과 끝부분의 타이밍 포인트만 나와있다. 그러므로 그 중간을 채워줘야 하는데, 이를 TimingPoints에 넣어두면 보기 더러워져서 일부러 분리를 하였다.

 

이 게임은 4키용 게임을 만들기 위해 일부로 case의 숫자를 하드코딩 했으나, 가변을 원한다면 512/(뭐시기저시기) 공식이 있으니 사용하면 된다.

 

이와 함께 맵 기본 정보들도 불러와주면 맵 로딩은 끝난다.

// General 블럭 로딩 예시
void ReadProperty_General(char* str) // 예시
{
	char nstr[200] = { NULL };
	char* ptr = strtok(str, ":");
	int i = 0;
	Trim(ptr, nstr);
	if (strcmp(nstr, "AudioFilename") == 0) {
		ptr = strtok(NULL, ":");
		Trim(ptr, nstr);
		strcpy(M_General.AudioFilename, nstr);
	}
	else if (strcmp(nstr, "AudioLeadIn") == 0) {
		ptr = strtok(NULL, ":");
		Trim(ptr, nstr);
		M_General.AudioLeadIn = atoi(ptr);
	}else if (strcmp(nstr, "PreviewTime") == 0) {
		ptr = strtok(NULL, ":");
		Trim(ptr, nstr);
		M_General.PreviewTime = atoi(ptr);
	}
}

맵 로딩이 끝났으니 이제 노트를 화면에 뿌려주면 된다.

 

inline void Render() // 게임 플레이시 렌더링 함수
{
	hWnd = GetConsoleWindow();		// 자신의 콘솔 윈도우를 가져옴
	hInst = GetModuleHandle(NULL);	// 콘솔 핸들러 가져옴
	HDC hDC, hMemDC;		//표시할 메모리 할당
	static HDC hBackDC;		//표시전 렌더링할 함수 할당 (더블 버퍼링)
	HBITMAP hBackBitmap, hOldBitmap, hNewBitmap; //표시할 비트맵
	BITMAP Bitmap;	//비트맵 선언

	hDC = GetDC(hWnd);

	hMemDC = CreateCompatibleDC(hDC);
	hBackDC = CreateCompatibleDC(hDC);

	hBackBitmap = CreateCompatibleBitmap(hDC, 1000, 500);		//렌더링 할 팔레트 크기 선언
	hOldBitmap = (HBITMAP)SelectObject(hBackDC, hBackBitmap);	//표시되는 비트맵

	hNewBitmap = LoadBitmap(hInst, MAKEINTRESOURCE(IDB_BITMAP1)); //노트 이미지 로드
	GetObject(hNewBitmap, sizeof(BITMAP), &Bitmap);
	SelectObject(hMemDC, hNewBitmap);

	note_width = Bitmap.bmWidth;
	note_height = Bitmap.bmHeight;
	for (int i = PlayTimer; i < PlayTimer + READ_NOTE_MIL; i++) // 화면에 그릴 노트 범위
	{
		if (PlayTimer < 0) //타이머가 작동하지 않으면 나가기
			break;
		for (int j = 0; j < 4; j++) // 각 키마다 확인
		{
			if (ImagePoints[i][j] == 1) // 위치에 노트가 있을경우
			{
				/*if (i == TimingPoints[0].time + (int)(floor(TimingPoints[0].beatLength / 4.0 * k))) k++; // 최적화 코드였으나 구현 귀찮음으로..
				else continue;*/
				GdiTransparentBlt(hBackDC, j* Bitmap.bmWidth + Start_Pos, (i - PlayTimer - 500)*(-0.9), Bitmap.bmWidth, Bitmap.bmHeight, hMemDC, 0, 0, Bitmap.bmWidth, Bitmap.bmHeight, RGB(255, 0, 228));
				// 단노트 표시
			}
			else if (ImagePoints[i][j] == 2) {
				GdiTransparentBlt(hBackDC, j * Bitmap.bmWidth + Start_Pos, (i - PlayTimer - 500) * (-0.9), Bitmap.bmWidth, Bitmap.bmHeight, hMemDC, 0, 0, Bitmap.bmWidth, Bitmap.bmHeight, RGB(255, 0, 228));
				// 롱노트 표시
			}
		}
	}
	DeleteObject(hNewBitmap); // 노트 오브젝트 삭제

	BitBlt(hDC, 0, 0, 1000, 500, hBackDC, 0, 0, SRCCOPY); // 백그라운드에서 그린 이미지 콘솔에 그리기

	DeleteObject(SelectObject(hBackDC, hBackBitmap)); // 사용한 오브젝트 정리
	DeleteDC(hBackDC);
	DeleteDC(hMemDC);

	ReleaseDC(hWnd, hDC); // 윈도우 할당 해제
}

먼저 처음 구현 할때는 while 문으로 i++ 시켜가면서 맵을 표시해봤다.

그러나 이렇게 하면 노래는 노래대로 나오는데, 노트 표시 속도가 따라가지 못하는 것이였다.

그래서 쓰레드를 이용해 PlayTimer 변수를 1 milisecond 단위로 증가 시켜주었고, 이를 여기에서 사용한다.

 

큰 공간은 필요없음으로 1000x500 크기의 캔버스를 준비해 주시고 여기에 노트들을 그렸다.

 

처음에는 노트 자체에 뭔가를 부여해서 클릭하면 사라지고 이런식으로 만들려고 했었다. 그런데 이렇게 하면  프로그램이 굉장히 복잡해 질 뿐더러 성능도 잘 나오지 않을것이다. 화면에 수십개의 각가지 동작을 하는 오브젝트가 있는데 프로그램이 잘 돌아 가겠는가

 

그래서 일반 노트 이미지를 현재 타이밍 ~ 타이밍 포인트 + 3000mil 뒤의 노트까지만 표시를 해서 최적화를 해주었다.

 

여기에 깜빡이거나 렉걸리듯이 딱딱하게 움직이는 점을 해소하기 위해 더블 버퍼링이라는 기술을 넣었다.

 

출처: https://s.hoto.dev/r/aUIiWiOQ0u

이런 기술인데, 뒤에서 그려주고, 앞에서 그대로 뿌려주는 기술이다.

앞에서 뿌리면 그려지는 모습이 다 보이니까 뚝뚝 끊기는 느낌이 드는데, 이 기술을 쓰면 그럴 일이 없다.

 

while(1) 해서 위의 코드를 돌려주면 된다.

 

다음은 판정이다.

void* CheckKeyPress(void* a) // 키 눌렀는지 체크 (스레드 함수)
{
	while (map_playing)
	{
	  //0x0000 : 이전에 누른 적이 없고 호출 시점에도 눌려있지 않은 상태
	  //0x0001 : 이전에 누른 적이 있고 호출 시점에는 눌려있지 않은 상태
	  //0x8000 : 이전에 누른 적이 없고 호출 시점에는 눌려있는 상태
	  //0x8001 : 이전에 누른 적이 있고 호출 시점에도 눌려있는 상태

		if (GetAsyncKeyState(KEY_A) & 0x0001) //Key A를 눌렀을 경우
			KeyDown[0] = TRUE;
		if (GetAsyncKeyState(KEY_B) & 0x0001)
			KeyDown[1] = TRUE;
		if (GetAsyncKeyState(KEY_C) & 0x0001)
			KeyDown[2] = TRUE;
		if (GetAsyncKeyState(KEY_D) & 0x0001)
			KeyDown[3] = TRUE;
		HitNote();
	}
	return;
}

스레드로 돌려주면 간단한 소스이다.

내 설정은 KEY_A,B,C,D는 각각 A,S,L,; 이다. 각 키의 아스키 코드를 입력해 주면 된다.

 

void HitNote() // 노트 클릭 체크
{
	if (KeyDown[0]) {    // 현재 해당 키를 눌렀을 경우
		KeyDownProcess(0);   // 참이라면 KeyDownProcess함수 실행
		KeyDown[0] = FALSE;     //  KeyDown 변수를 거짓으로 되돌림
								//  꾹 누르는것을 방지하기 위해서
	}
	if (KeyDown[1]) { // 위와 동일함
		KeyDownProcess(1);
		KeyDown[1] = FALSE;
	}
	if (KeyDown[2]) {
		KeyDownProcess(2);
		KeyDown[2] = FALSE;
	}
	if (KeyDown[3]) {
		KeyDownProcess(3);
		KeyDown[3] = FALSE;
	}

	Rate = (floor(Combo / 10) * 0.85) + 1; // 콤보 보너스 수정
}

HitNote 함수이다.

아래에 보면 Rate 변수가 있는데, 콤보에 따라서 점수 보너스가 가는 것이다.

 

void KeyDownProcess(int k) // 키 입력 처리. k : 키 위치 (0,1,2,3)
{
	for (int i = -140; i <= 140; i++) {
		if (PlayTimer + i < 0) continue; //배열 범위 규정을 위한 코드

		if (NotePoints[PlayTimer + i][k] == 1) //노트가 있을 경우
		{
			int abs_v = abs(i);						//오차를 구하기 위해 오차의 절대값

			if (abs_v <= 16) IncKool();				//오차 16ms 이내 : Kool
			else if (abs_v <= 35) IncCool();		//오차 35ms 이내 : Cool
			else if (abs_v <= 85) IncGood();		//오차 85ms 이내 : Good
			else if (abs_v <= 140) IncBad();		//오차 140ms 이내 : Bad
			NotePoints[PlayTimer + i][k] = 0;		//노트 친것으로 표시
			ImagePoints[PlayTimer + i][k] = 0;		//Render 함수에서 해당 노트 표시 안함
			break;
		}
	}
}

대망의 판정과 키 인식의 함수이다.

키를 누를때마다 판정을 하는데, 만약 노트가 있으면 판정을 하고 TimingPoints와 ImagePoints에서 지워 화면에 없에버린다.

 

중간점검

일단 판정선과 가이드라인 정도만 추가해서 보면 이런 모양이 나오게 된다.

 

다음 글에서는 판정 이미지와 쓰레드 결과물 등등을 출력해볼 것이다.

 


2020/01/31 - [C] - [C++] using namespace std; 를 사용하면 안되는 이유

2020/01/31 - [C] - [C++] std::string 앞 뒤에 개행문자 제거

2019/05/10 - [PHP] - [Centos 7] PHP 7.3 설치

2020/01/28 - [분류 전체보기] - 티스토리 애드센스 ads.txt 해결 방법

2020/01/27 - [서버 개발일지] - 글 하나로 2일 만에 끝내는 G Suite for Education 등록

2019/01/01 - [분류 전체보기] - yum 으로 아파치 웹서버 최신 버전 업데이트 하기

댓글