테트리스 개발 7일차

나묘쿠·2022년 1월 20일
0

테트리스

목록 보기
7/8
post-thumbnail

몇 명의 친구들에게 저번에 만들었던 샘플을 보내서 개선사항이나 버그등의 피드백을 받았다. 다행히도 버그는 없었다. 사실 여기까지 했으면 완성이라 치고 그만두고 폴더 한쪽에 치워두어도 될 법 한데 욕심이 많이 났다. 가장 먼저 음악을 넣었다. 음악이 구현하는데 가장 오래걸렸던 것 같다.

처음엔 Playsound 함수를 사용하여 음악을 재생하려하니 한번에 하나의 음원밖에 재생하지 못 하는 한계에 부딪혀 효과음과 배경음악 중 하나는 포기해야했다. 그래서 처음엔 배경음악을 포기했다. 그러다 효과음이 아쉬워 효과음을 넣어보려고 "pthread.h" 헤더를 이용하여 멀티쓰레드를 이용한 꼼수를 사용해 작동하는지 확인해보려고 했더니 pthread 헤더가 다른 헤더와 충돌을 일으켜 무슨 짓을 해도 컴파일이 안 되는거다. 헤더도 뜯어보고 스택오버플로우와 여러 블로그들과 msdn까지 읽어가며 열심히 공부해도 LNK2001 에러를 뿜어대며 나를 괴롭혔다. 그러다 다른 쓰레드 헤더가 존재한다는 것을 알고 process.h 헤더를 이용해 구현했더니 컴파일은 되나 Playsound 함수의 한계를 극복하긴 힘들었다.
찾아보니 MCI라는 API가 있다는 것을 알았다. 사실 원래 알고있었긴 했는데 여기저기 참고해봐도 작동이 안 되더라. 그래서 포기하고 쓰레드를 이용한 꼼수를 시도해봤었다. 어쨌든 해결을 못 하고 있어서 좌절에 빠져있었는데, 같이 코딩을 하는 친구가 MCI를 가져와서 멀쩡히 구현시키는 것이다. 기쁨과 허망이 동시에 느껴졌다.

나는 줄이 사라지는 효과음, 블록이 놓이는 효과음, 그리고 bgm을 MCI를 이용해 구현하고 게임이 시작될 때의 효과음, 그리고 게임오버 됐을 때의 효과음은 Playsound 함수로 구현하였다.
Playsound 함수는 사용이 쉽다. 이 함수를 사용할 때에는 Dev C++은 추천하지 않고 비주얼스튜디오로 컴파일 하는 것을 추천한다. 이유는 밑에 후술하겠다.

#include <windows.h>
#include <mmsystem.h>
#pragma comment(lib,"winmm.lib")

를 함수 맨 위에 선언해 준 다음 음원을 프로젝트 폴더가 있는 곳에 붙여 넣고 (보통 솔루션 탐색기에서 프로젝트 우클릭 - 파일탐색기에서 폴더 열기 누르면 프로젝트 폴더가 열린다)

PlaySound(TEXT("gameover.wav"), 0, SND_ASYNC);

음악을 재생할 곳에 이렇게 작성해주면 된다.
"gameover.wav" 이렇게만 작성하면 파일의 경로가 상대경로로 지정되어 음원이 프로젝트와 같은 폴더에 있기만 하면 된다. exe로 컴파일 한 뒤에도 exe와 같은 폴더에 음원을 넣어주면 된다.

SND_ASYNC는 음원에 재생명령을 내린 뒤 음원이 끝날 때 까지 기다리지 않고 다음 코드를 실행하게 하는 것이다.
SND_LOOP을 사용하면 음원을 반복재생 시키는 코드이다.
두개를 같이 사용하려면 SND_ASYNC|SND_LOOP 이렇게 사용하면 된다.

그리고 대망의 MCI다. 난 이 API에 대해 자세히는 모르지만 작동을 시킬줄은 안다. 그래서 이번엔 작동되는 코드와 간단한 역할들은 주석을 달겠지만 자세히 설명하지는 않겠다.

MCI는 Playsound에서 코드 맨 위에 선언된 헤더들 밑에
#include <Digitalv.h>
를 하나 더 선언해주어야 한다.

그 다음엔

MCI_OPEN_PARMS openBgm;		//bgm 구조체
MCI_PLAY_PARMS playBgm;
MCI_OPEN_PARMS openfallsound;		//블록착지음 구조체
MCI_PLAY_PARMS playfallsound;
MCI_OPEN_PARMS openlinesound;		//줄 삭제음 구조체
MCI_PLAY_PARMS playlinesound;

int dwID;		//음악 파일 불러오는 값
int dwID1;
int dwID2;

이렇게 선언을 해줘야 하더라. dwID는 bgm 재생에 쓰일 값, dwID1은 블록착지음 재생에 쓰일 값...잘 몰라서 찾아봤다.

dwID는 MCI_OPEN_PARMS이 담고 있는 wDeviceID를 받기 위한 변수다.

출처: https://kiffblog.tistory.com/151 [Analogrammer]

무...슨 역할을 하는지는 알았지만 역시 자세히는 모르겠다. 그리고 이 블로그의 작성자는 코드를 블로그에 올리면서 수정한 것 같다. 이 블로그의 코드를 사용하면 작동이 안 된다.

void MainSound(int a) 
{
	openBgm.lpstrElementName = TEXT("mainbgm.wav"); //파일 오픈
	openBgm.lpstrDeviceType = TEXT("mpegvideo"); //mp3 형식
	mciSendCommand(0, MCI_OPEN, MCI_OPEN_ELEMENT | MCI_OPEN_TYPE, (DWORD)(LPVOID)&openBgm);
	dwID = openBgm.wDeviceID;
	if(a==0)		//재생신호시 재생
		mciSendCommand(dwID, MCI_PLAY, MCI_DGV_PLAY_REPEAT, (DWORD)(LPVOID)&openBgm); //음악 반복 재생
	else	//재생 정지 신호시 정지
		mciSendCommand(dwID, MCI_PAUSE, MCI_DGV_PLAY_REPEAT, (DWORD)(LPVOID)&openBgm);		//재생 정지
}

void playsoundline()
{
	openlinesound.lpstrElementName = TEXT("line.wav"); //파일 오픈
	openlinesound.lpstrDeviceType = TEXT("mpegvideo"); //mp3 형식
	mciSendCommand(0, MCI_OPEN, MCI_OPEN_ELEMENT | MCI_OPEN_TYPE, (DWORD)(LPVOID)&openlinesound);
	dwID1 = openlinesound.wDeviceID;
	mciSendCommand(dwID1, MCI_SEEK, MCI_SEEK_TO_START, (DWORD)(LPVOID)NULL); //음원 재생 위치를 처음으로 초기화
	mciSendCommand(dwID1, MCI_PLAY, MCI_NOTIFY, (DWORD)(LPVOID)&openlinesound); //음악을 한 번 재생
}

void playsoundfall() 
{
	openfallsound.lpstrElementName = TEXT("fall.wav"); //파일 오픈
	openfallsound.lpstrDeviceType = TEXT("mpegvideo"); //mp3 형식
	mciSendCommand(0, MCI_OPEN, MCI_OPEN_ELEMENT | MCI_OPEN_TYPE, (DWORD)(LPVOID)&openfallsound);
	dwID2 = openfallsound.wDeviceID;	
	mciSendCommand(dwID2, MCI_SEEK, MCI_SEEK_TO_START, (DWORD)(LPVOID)NULL); //음원 재생 위치를 처음으로 초기화
	mciSendCommand(dwID2, MCI_PLAY, MCI_NOTIFY, (DWORD)(LPVOID)&openfallsound); //음악을 한 번 재생

}

이렇게 하고 재생하고싶은 곳에 재생하고싶은 함수를 선언해주면 된다.

음악은 해결 됐고, 그 다음은 메인 화면을 조금 더 있어보이게 만들어보려고 한다.

이렇게 만들었다. 방향와 회전은 오락실 기계에서 왼손은 조이스틱으로 이동, 오른손은 버튼으로 회전을 하는 것을 따와서 왼손은 이동담당, 오른손은 회전담당으로 키배치를 바꾸었다. 너무 불편해서 다시 s, d를 회전, 방향키를 이동으로 바꾸었다. 대신 방향키 윗 키를 우측 회전으로 추가하였다. 하드드랍은 밑에서 설명하겠다.

void printT(int x, int y)
{
	gotoxy(x, y);   printf("▣▣▣▣▣▣");
	gotoxy(x, y+1); printf("    ▣▣    ");
	gotoxy(x, y+2); printf("    ▣▣    ");
	gotoxy(x, y+3); printf("    ▣▣    ");
	gotoxy(x, y+4); printf("    ▣▣    ");
	gotoxy(x, y+5); printf("    ▣▣    ");
	gotoxy(x, y+6); printf("    ▣▣    ");
	gotoxy(x, y+7); printf("    ▣▣    ");
	gotoxy(x, y+8); printf("    ▣▣    ");
}

이렇게 알파벳을 출력하는 함수를 만들어준 다음에

int start(void)		//스타트 화면 
{
	srand(time(NULL));		//시간에서 시드값 끌어오기
	system("mode con cols=160 lines=40");		//콘솔창 크기 지정 
	cursorview();		//커서 숨기기 
	system("title Tetris by namaek");		//타이틀 명 변경 

	int i = 0;

	system("cls");		//화면 초기화 
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 4);
	printT(39, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 12);
	printE(53, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 14);
	printT(67, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 10);
	printR(81, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 11);
	printI(95, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 9);
	printS(109, 6);
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 7);

	gotoxy(50, 34);
	printf("회전 : 방향키 좌, 우 / 이동 : A, S, D / 하드드랍 : 스페이스 바");
	gotoxy(117, 16);
	printf("made by namaek");

	return(firstSC());
}

이렇게 start 함수를 다시 만들어주었다. firstSC함수는 내가 예전에 만든 함수를 가져와서 개조했다.
">>"가 반짝거리며 현재 커서의 위치를 표시해주고 선택시 선택한 난이도의 글자가 반짝거리며 효과음이 난다.

int firstSC(void)		//선택화면 출력
{
	int input = 0;
	int CursorS = 26;
	int num = 0;
	int i = 0;

	gotoxy(74, 26);
	printf("Beginner");
	gotoxy(74, 28);
	printf("Amateur");
	gotoxy(74, 30);
	printf("Expert");
	gotoxy(74, 32);
	printf("Exit");
	gotoxy(71, 26);
	printf(">>");

	while (1)
	{
		for (i = 0; i < 10; i++)
		{
			if (kbhit())
			{
				int input = getch();

				if (input == 13)		//엔터키 입력
				{
					if (CursorS == 26)
					{
						PlaySound(TEXT("start.wav"), 0, SND_ASYNC);		//선택 효과음

						for (i = 0; i < 5; i++)		//선택시 반짝거림 
						{
							gotoxy(74, 26);
							printf("        ");
							Sleep(100);
							gotoxy(74, 26);
							printf("Beginner");
							Sleep(100);
						}
						system("cls");
						return 1;
					}

					else if (CursorS == 28)
					{
						PlaySound(TEXT("start.wav"), 0, SND_ASYNC);

						for (i = 0; i < 5; i++)		//선택시 반짝거림 
						{
							gotoxy(74, 28);
							printf("       ");
							Sleep(100);
							gotoxy(74, 28);
							printf("Amateur");
							Sleep(100);
						}
						system("cls");
						return 2;
					}

					else if (CursorS == 30)
					{
						PlaySound(TEXT("start.wav"), 0, SND_ASYNC);

						for (i = 0; i < 5; i++)		//선택시 반짝거림 
						{
							gotoxy(74, 30);
							printf("      ");
							Sleep(100);
							gotoxy(74, 30);
							printf("Expert");
							Sleep(100);
						}
						system("cls");
						return 3;
					}

					else
					{
						return 0;
					}
				}

				else if (input == 224)
				{
					input = getch();

					switch (input)
					{
					case U:		//방향키 위
					{
						gotoxy(71, CursorS);
						printf("  ");
						if (CursorS != 26)
							CursorS = CursorS - 2;
						else
							CursorS = 32;
						break;
					}

					case D:		//방향키 아래
					{
						gotoxy(71, CursorS);
						printf("  ");
						if (CursorS != 32)
							CursorS = CursorS + 2;
						else
							CursorS = 26;
						break;
					}
					}
				}
			}
			Sleep(10);
		}

		if (num == 0)
		{
			gotoxy(71, CursorS);
			printf(">>");
			num = 1;
		}

		else
		{
			gotoxy(71, CursorS);
			printf("  ");
			num = 0;
		}
	}
}

비기너부터 1, 2, 3 을 반환하고 종료 선택시 0을 반환한다.
선택된 난이도에 따라 블록이 내려오는 속도, 그리고 시간이 지날수록 빨라지는 블록의 최고속도가 달라질 것이다. 아 또, 점수 시스템을 만들었다.

이렇게 만들었는데, 그냥 한 칸씩 내려갈 시 1점씩, 하드드랍 시 드랍한 y좌표당 2점을 배점하고 한 줄을 없앨시 100점, 더블은 300점, 트리플은 500점, 테트리스는 800점을 배점했다. 하드드랍은 테트리미노가 내려갈 수 있는 최대치까지 한번에 내려가는 것을 의미한다.

void godown(int a)		//하드드랍 
{
	rvprintsq(acblock);
	while (blockcheckdown() == 0)		//하단이동이 방해받을 때 까지 하단으로 이동
	{
		y++;
		if(a==0)
			score += 2;		//강제 하드드랍이 아닐 시 이동한 y좌표당 점수 2점
	}
		
	printsq(acblock);
	return;
}

하드드랍은 이렇게 구현했다. 인자를 받게 하여 a가 0일 때와 아닐 때를 구분하여 점수를 기록하게 하였는데, 이것은 플레이어가 하드드랍을 사용하는 경우와 프로그램이 하드드랍을 강제할 때를 염두하여 프로그램이 하드드랍을 강제하면 점수를 얻게 하지 못하게 구분해둔 것이다.

int main(void)
{
	int speed;
	int num;

	while (1)		//게임 종료시 메인화면으로 복귀
	{
		clock_t starttime = 0;
		clock_t endtime = 0;
		int speednum = 0;
		score = 0;

		num = start();
		if (num == 1)		//비기너
			speed = 30;

		else if (num == 2)		//아마추어
			speed = 20;

		else if (num == 3)		//엑스퍼트
			speed = 10;

		else        //종료
			return 0;
	
		MainSound(0);
	
		scrline();
		gameline();
		gameblockset();
		
		changeac(newblockrand());		//처음 대기 블록 5개와 첫 활성화 될 블록 지정 
		changeac(newblockrand());
		changeac(newblockrand());
		changeac(newblockrand());
		changeac(newblockrand());
		changeac(newblockrand());

		SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 7);		//점수 색깔 지정 
		gotoxy(10, 7);
		printf("SCORE : %d", score);		//점수 전광판

		starttime = clock();		//게임을 시작할 때의 시간 기록
		while (1)
		{
			currentblock = setcolor(acblock);
			SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), currentblock);		//블록 색깔 지정 
			moveac(speed);
			playsoundfall();
		
			setgameblock();
			scangameblock();

			gotoxy(10, 7);
			SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 7);		//블록 색깔 지정 
			printf("SCORE : %d", score);
			
			endtime = clock();		//현재 시간 기록
			if (endtime-starttime>=60000 && speednum==0)		//게임 시작 1분째에 속도 변화
			{
				if (num == 1)
					speed -= 10;

				else if (num == 2)
					speed -= 7;

				else if (num == 3)
					speed -= 3;

				speednum++;
			}
			else if (endtime - starttime >= 120000 && speednum==1)		//게임 시작 2분째에 속도 변화
			{
				if (num == 1)
					speed -= 10;

				else if (num == 2)
					speed -= 7;

				else if (num == 3)
					speed -= 3;

				speednum++;
			}
			else if (endtime - starttime >= 180000 && speednum == 2)		//게임 시작 3분째에 속도 변화
			{
				if (num == 1)
					speed -= 7;

				else if (num == 2)
					speed -= 4;

				else if (num == 3)
					speed -= 3;

				speednum++;
			}

			if (scangameover() == 1)		//가장 윗줄에 블록이 있는지 확인
				break;

			changeac(newblockrand());
		}

		MainSound(1);		//bgm 정지
		gameover();		//게임오버 화면
	}
	
	Sleep(5000);
	return 0;
}

그래서 메인함수를 이렇게 재지정 해주었다. clock 함수를 이용하여 게임이 시작된 시각을 현재의 시각에서 빼주어 게임이 진행된 시간을 계산하여 60000밀리세컨드(1분), 120000밀리세컨드(2분), 180000밀리세컨드(3분) 마다 속도를 바꿔주었다. 아 또 printgameblock을 finishedgameblock 함수 안으로 이동시켜 한 번에 완성된 줄이 모두 사라지는 것이 아닌 한 줄씩 사라지게 하였다.

그리고 gameover 함수에 대해 알아보자면

이렇게 만들어주었다. 딱히 특별한건 없다.

moveac 함수도 바꿔주었다. 가이드 블록을 추가하여 하드드랍시 떨어질 위치를 미리 보여주게 하였다. 밑의 흰 블록이 가이드 블록이다. 또, 하드드랍과 이미 더 이상 내려갈 수 없는 상황에서 아래로 이동을 누른게 아닌 방법으로 하단으로 이동했을 때 바닥과 닿을 시 바로 굳어버리는 것이 아닌 1초동안 움직임과 회전이 가능하게 하였다.

void moveac(int a)		//활성화된 블록의 좌표의 이동 
{
	int i;
	x = 18;		//블록이 출력되기 시작할 x좌표
	y = 7;		//블록이 출력되기 시작할  y좌표
	int buff = 0;
	clock_t start = 0;
	clock_t end=0;
	float duration = 0;

	topgameline();		//상단의 블록 경계선 출력
	guide();		//가이드 블록 출력
	start =NULL;
	while (1)	// 무한반복
	{
		for (i = 0; i < a; i++)	//a*10 밀리세컨드 = 블록이 한칸씩 내려오는 시간
		{
			if (start != NULL)		//땅에 닿고 1초 카운트가 진행중 일 때
			{
				end = clock();		//현재시각
				duration = (float)(end - start);		//카운트 계산

				if (duration >= 1000)		//1초 경과시
				{
					godown(1);		//강제 하드드랍 후 고정
					return;		
				}
			}

			if (_kbhit())	//키보드 입력이 있다면
			{
				buff = kb();

				if (buff == 3)		//방해받지 않은 하단 이동
				{
					if (start != NULL)		//땅에 닿고 1초 카운트가 진행중 일 때
					{
						start = NULL;		//카운트 초기화
						end = NULL;
					}
						
					i = 0;        //밑으로 이동할 때 지연시간 초기화(0.1초 지났을 때 밑으로 하강시 다시 0.3초를 기다려야 1칸 하강)
				}

				else if (buff == 1)		//방해받은 하단 이동시 고정
					return;
			}
			Sleep(10);
		}

		buff = moveD();		//a*10밀리세컨드 마다 한 칸씩 아래로 이동

		if (buff == 1)		//방해받은 이동일 때
		{
			if(start == NULL)		//카운트 시작
				start = clock();
		}
		else        //방해받지 않은 이동일 때
		{
			if (start != NULL)		//카운트 초기화
			{
				start = NULL();
				end = NULL();
			}
		}
	}
}

자세한건 주석을 참고하길 바란다.
guide함수는 turnL,turnR, moveL, moveR 안에 넣어두었다.

int moveD(void)		//하단으로 이동 
{
	if (blockcheckdown() != 1)
	{
		rvprintsq(acblock);		//잔상 제거
		y += 1;		//하단으로 이동
		printsq(acblock);		//블록 출력  
		score += 1;		//점수 1점 추가
		return 0;
	}

	else
		return 1;
}

void moveR(void)		//우로 이동 
{
	if (blockcheckmoveR() != 1)		//간섭 없을 시 
	{
		rvprintsq(acblock);		//잔상 제거
		
		x += 2;		//좌표 이동 
		guide();
		printsq(acblock);		//블록 출력 
	}
}

이렇게 말이다. moveD에 guide 함수가 없는 이유는 하단으로만 이동시 블록이 미리 떨어질 곳의 위치와 모양이 변하는 것이 아니기 때문이다. guide 함수는

void rvprintguide(int arr[4][4], int a, int b)		//가이드 블록 출력 
{
	int i, j;

	if (excurrent != currentnum)		//놓인 블록이 지워지지 않게
		return;

	for (i = 0; i < 4; i++)
	{
		gotoxy(a, b + i);

		for (j = 0; j < 4; j++)
		{
			if (arr[i][j] == 1)		//블록이 없어야 할 공간이면 출력 
				printf("  ");

			else if (arr[i][j] == 0)		//아니라면 다음 칸으로 이동 
				gotoxy(a + 2 * (j + 1), b + i);
		}
	}
}

void printguide(int arr[4][4], int a)		//가이드 블록 출력 
{
	int i, j;

	for (i = 0; i < 4; i++)
	{
		gotoxy(x, a + i);

		for (j = 0; j < 4; j++)
		{
			if (arr[i][j] == 1)		//블록이 있어야 할 공간이면 출력 
				printf("▣");

			else if (arr[i][j] == 0)		//아니라면 다음 칸으로 이동 
				gotoxy(x + 2 * (j + 1), a + i);
		}
	}
}

int guidecheckdown(void)		//가이드 블록 최대 하강 좌표 계산
{
	int i, j;

	for (i = 3; i >= 0; i--)
	{
		for (j = 0; j < 4; j++)
		{
			if (acblock[i][j] != 0 && gameblock[exy - 7 + 1 + i][(x - 10) / 2 + j] != 0)		//간섭시 1 반환 
				return 1;
		}
	}

	return 0;
}

void guide(void)		//가이드 블록 출력
{
	int i, j;
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), 7);

	rvprintguide(exacblock, exx, exy);		//가이드 블록 잔상 제거

	exy = y;		

	while (guidecheckdown() == 0)		//가이드 블록이 내려갈 수 있는 최대 값까지 이동
		exy++;

	exx = x;		//exx값 갱신
	printguide(acblock, exy);		//가이드 블록 출력

	for (i = 0; i < 4; i++)		//현재 가이드 블록의 모양 저장
	{
		for (j = 0; j < 4; j++)
			exacblock[i][j] = acblock[i][j];
	}
	excurrent = currentnum;		//현재 가이드 블록의 코드 위치 저장
	SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), currentblock);		//텍스트 색 현재 블록으로 지정 
}

이처럼 작성해주었다.

이렇게 또 하나의 밤샘 여정이 끝났다. 고등학교 때는 게임을 하느라 바빴고, 성인이 되어서는 재수를 하며 무뎌진 코딩 실력을 대학교 수업을 들으며 뼈져리게 느꼈다. 이렇게 완성이 기다려지고 설레는 프로젝트는 정말 오랜만이었던 것 같다. 너무 기분이 좋다.

확인해보니 완성된 코드는 1826줄 이었다. 줄이려면 아마 충분히 줄일 수 있을 것이지만 이정도면 꽤나 큰 도전이었던 것 같다. 무뎌진 코딩실력을 다듬기엔 나무랄 것 없이 좋은 프로젝트였다. 함수들의 활용과 알고리즘의 작성 뿐만 아니라 넓게 나아가 게임 디자인의 기초와 컴파일러의 사용까지 정말 많은 것을 공부했다. 2022년을 이 프로젝트로 시작하게 되어 정말 다행이라 생각한다. 그리고 어떻게 보면 발목이 부러진 것이 이 프로젝트를 완성시키는 계기가 되었던 것 같다. 발목이 멀쩡했다면 절대 책상에 앉아서 이렇게 밤을 새는 일 없이 여기저기 놀러다녔을 것이다. 더욱 더 자기계발에 매진하는 2022년이 되길 바라며 게임 플레이 영상을 올리며 글을 마친다.

유튜브링크

*3/8 추가 : 깃허브에 전체 코드 업로드
깃허브

profile
사고의 흐름을 따라가 보자.

0개의 댓글