Posts [so_long] so_long 구현 과정 및 MLX 라이브러리 활용
Post
Cancel

[so_long] so_long 구현 과정 및 MLX 라이브러리 활용

플로우차트

so_long.drawio.png

화면 띄우기

mlx 라이브러리를 이용해서 화면을 띄운다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// main.c
#include "./mlx/mlx.h"

// 윈도우 크기
#define WINDOW_WIDTH 500
#define WINDOW_HEIGHT 500

int main(void)
{
	void *mlx_ptr;
	void *win_ptr;

	mlx_ptr = mlx_init();
	win_ptr = mlx_new_window(mlx_ptr, WINDOW_WIDTH, WINDOW_HEIGHT, "42mlx");
	mlx_loop(mlx_ptr);
	return (0);
}

창을 띄우기 위해서는 mlx 포인터와 window 포인터가 필요하다.

  • mlx 포인터는 mlx_init 함수를 사용해서 초기화 한다.
  • window 포인터는 mlx_new_window 함수를 사용해서 초기화한다.
    • 함수의 매개변수로 mlx 포인터, 창의 너비, 창의 높이, 창의 제목을 넘겨준다.

각 포인터를 초기화 했다면 mlx_loop 함수를 사용한다.

  • mlx_loop 함수는 mlx 포인터를 매개변수로 받는 함수이다.
  • window 포인터에 저장된 정보를 바탕으로 키보드 또는 마우스의 입력을 기다린다.

아래의 명령어를 입력해서 컴파일한다.

1
gcc -Lmlx -lmlx -framework OpenGL -framework AppKit main.c

실행 파일을 실행하면 아래와 같은 검은 창이 표시된다.

1

프로그램을 종료하기 위해서는 터미널에서 control + c 로 중지하면 된다.

ESC를 입력하거나 빨간색 종료 버튼을 눌러도 프로그램이 종료되는 기능은 이후에 설정할 것이다.

키보드 hook 연결하기

key_code

출처: eastmanreference

macOS는 키보드의 키마다 고유한 숫자를 지정을 해놓았다.

과제에서는 WASD 또는 방향키를 사용해서 캐릭터를 움직이게 할 수 있고, ESC 버튼을 눌렀을 때 화면이 종료되도록 하는 것이 목표이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// main.c
#include "./mlx/mlx.h"
#include <stdlib.h>
#include <stdio.h>

// 윈도우 크기
#define WINDOW_WIDTH 500
#define WINDOW_HEIGHT 500

// 키보드 key code
#define KEY_W		13
#define KEY_A		0
#define KEY_S		1
#define KEY_D		2
#define KEY_ESC	53

// X11 interface의 EVENT code
#define X_EVENT_KEY_PRESS 2
#define X_EVENT_KEY_RELEASE 3

// 좌표와 움직인 횟수를 저장하는 구조체
typedef struct s_pos
{
	int x;
	int y;
	size_t moves;
} t_pos;

// 위치와 움직임 횟수를 초기화
void init_pos(t_pos *pos)
{
	pos->x = 0;
	pos->y = 0;
	pos->moves = 0;
}

// 키보드에서 손을 떼면 작동하는 함수
int release_key_hook(int key_code, t_pos *pos)
{
	if (key_code == KEY_W)
		pos->y -= 1;
	else if (key_code == KEY_A)
		pos->x -= 1;
	else if (key_code == KEY_S)
		pos->y -= 1;
	else if (key_code == KEY_D)
		pos->x += 1;
	else if (key_code == KEY_ESC)
	{
		printf("✅ Exit the game. Bye! 👋 \n");
		exit(0);
	}
	if (key_code == KEY_W || key_code == KEY_A || key_code == KEY_S || key_code == KEY_D)
	{
		pos->moves += 1;
		printf("x: %d, y: %d, moves: %zd\n", pos->x, pos->y, pos->moves);
	}
	return (0);
}

int main(void)
{
	void *mlx_ptr;
	void *win_ptr;
	t_pos *pos;

	mlx_ptr = mlx_init();
	win_ptr = mlx_new_window(mlx_ptr, WINDOW_WIDTH, WINDOW_HEIGHT, "42mlx");
	init_pos(&pos);
	mlx_hook(win_ptr, X_EVENT_KEY_RELEASE, 0, &release_key_hook, &pos);
	mlx_loop(mlx_ptr);
	return (0);
}

우선 mlx_hook 함수부터 알아보자. 이 함수는 특정 이벤트(key press, key release 등)가 발생했을 때, 실행시킬 함수를 등록하는 것이다. 자바스크립트의 이벤트 리스너와 비슷하다.

1
2
int	mlx_hook(void *win_ptr, int x_event, int x_mask,
                 int (*funct)(), void *param);

매개변수는 다음과 같다.

  • win_ptr : window 포인터를 의미한다.
  • x_event : 윈도우를 X라고 부르는데, 윈도우에서 발생하는 특정 이벤트를 설정한다. 2는 키를 눌렀을 때(press), 3는 키에서 손을 뗄 때(release) 발생하는 이벤트를 의미한다. 이 외 다른 이벤트도 존재하는데, 링크에서 참고할 수 있다.
  • x_mask : 특정 이벤트에 대해 허용 또는 방지할 수 있도록 하는 기능이지만, macOS에서는 지원하지 않기 때문에 0으로 설정한다.
  • funct : x_event 가 발생했을 때 작동할 함수 포인터를 의미한다.
  • param : funct 에는 입력한 키와 함께 매개변수 param 이 전달된다. 이를 통해 변수의 값을 변경할 수 있다.
1
mlx_hook(win_ptr, X_EVENT_KEY_RELEASE, 0, &release_key_hook, &pos);

위의 함수는 키보드에서 손을 뗄 때 release_key_hook 함수를 실행하며, pos 라는 변수를 함께 해당 함수에 전달하도록 한다.

실행시키면 WASD를 누를 때마다 좌표 및 움직인 횟수가 바뀌는 것을 확인할 수 있으며, ESC를 누르면 창이 종료되며 터미널에서도 정상적으로 종료되는 것을 확인할 수 있다.

so_long_key_hook.gif

이미지 표시하기

이미지를 표시하기 위해서는 이미지 파일이 필요하다.

C언어에서는 xpm 파일을 사용해서 이미지를 표시할 수 있다. xpm 파일은 이미지를 C언어 문법 형태로 변환한 파일이다.

이미지는 16x16 또는 32x32, 64x64 형태로 표시해보고자 한다. 단, 16x16는 너무 작게 표시되기 때문에 32x32 이상으로 표시하는 것을 추천한다.

이미지 다운받기

이미지는 itch.io 에서 다운받을 수 있다. 이때, 태그를 32x32 또는 64x64로 검색하면 된다.

2.png

이미지 편집하기

그리고 다운받은 png 파일을 일정한 크기로 자르기 위해 이미지 크롭 사이트를 활용하면 된다.

3

사용하고자 하는 이미지를 업로드하고, 너비와 높이를 32로 설정한 다음 원하는 이미지를 잘라내서 저장하면 된다.

png를 xpm으로 변환하기

그 다음 png 파일을 xpm 파일로 변환하기 위해서는 xpm 변환기 사이트를 이용한다.

4.png

예제 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// main.c
#include "./mlx/mlx.h"

// 윈도우 크기
#define WINDOW_WIDTH 500
#define WINDOW_HEIGHT 500

// mlx와 window 포인터를 담는 구조체
typedef struct s_mlx
{
	void	*mlx_ptr;
	void	*win_ptr;
}	t_mlx;

// 이미지 정보를 저장하는 구조체
typedef struct s_game
{
	void	*land;
	void	*wall;
	void	*character;
	void	*collectible;
	void	*door;
	void	*fire;
}	t_game;

void	init_mlx(t_mlx *mlx)
{
	mlx->mlx_ptr = mlx_init();
	mlx->win_ptr = mlx_new_window(mlx->mlx_ptr, WINDOW_WIDTH, WINDOW_HEIGHT, "so_long");
}

void	init_images(t_mlx *mlx, t_game *game)
{
	int	img_width;
	int	img_height;

	game->collectible = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/collectible.xpm", &img_width, &img_height);
	game->land = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/land.xpm", &img_width, &img_height);
	game->wall = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/wall.xpm", &img_width, &img_height);
	game->character = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/cat_1.xpm", &img_width, &img_height);
	game->door = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/door.xpm", &img_width, &img_height);
	game->fire = mlx_xpm_file_to_image(mlx->mlx_ptr, "./images/fire.xpm", &img_width, &img_height);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->collectible, 0, 0);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->land, 16, 0);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->wall, 32, 0);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->character, 48, 0);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->door, 64, 0);
	mlx_put_image_to_window(mlx->mlx_ptr, mlx->win_ptr, game->fire, 80, 0);
}

int main(void)
{
	t_mlx		mlx;
	t_game	game;

	init_mlx(&mlx);
	init_images(&mlx, &game);
	mlx_loop(mlx.mlx_ptr);
	return (0);
}

xpm 이미지 파일을 변수에 저장하기 위해 mlx_xpm_file_to_image 함수를 이용한다.

해당 함수는 mlx 포인터와 이미지 파일의 경로, 이미지의 너비와 높이를 매개변수로 받는다.

이때, 이미지의 너비와 높이는 함수 내부에서 자동으로 계산한다.

그 다음 mlx_put_image_to_window 함수를 이용해서 원하는 좌표에 이미지를 표시할 수 있다.

1
2
int	mlx_put_image_to_window(void *mlx_ptr, void *win_ptr, void *img_ptr,
				int x, int y);

이때, 좌표는 좌측 상단부터 (0, 0)이다.

5

맵 파싱하기

게임 실행에 필요한 맵은 다음과 같은 형태를 가진다.

1
2
3
4
5
1111111111
10C0000001
1000C0E001
1P00000001
1111111111

맵 저장하기

여러 줄로 이루어진 맵을 아래와 같이 하나의 문자열로 합쳐서 저장했다.

(슬래시와 공백은 줄 구분을 위한 표시이기 때문에 실제로 저장하지 않는다.)

1
1111111111 / 10C0000001 / 1000C0E001 ...

이렇게 저장하면 벽으로 둘러 쌓였는지 확인하거나, 현재 플레이어의 위치를 구할 때 인덱스 하나만 사용하면 되기 때문에 편리하다는 장점이 있다.

예외 처리

벽으로 둘러쌓였는가?

벽으로 둘러 쌓여있는지 확인하기 위해서는 첫 번째 줄과 마지막 줄이 모두 벽인지 확인하면 된다.

중간에 있는 줄은 첫 번째 인덱스와 마지막 인덱스만 벽인지 확인하면 된다.

이때, 맵은 문자열 형태로 저장했기 때문에, 현재 인덱스가 몇 번째 줄에 해당하는지 계산 해야 한다.

맵의 최소 크기

맵 테두리는 벽으로 둘러쌓여 있기 때문에 벽을 제외한 나머지 공간은 최소 3칸이 확보되어야 한다는 뜻이다.

따라서 이를 만족하는 맵의 최소 크기는 3x5 또는 5x3 형태이다.

1
2
3
4
5
6
7
8
9
111
101
101
101
111

11111
10001
11111

1개 이상의 수집품, 플레이어, 출구

맵에는 최소 1개 이상의 수집품(Collectible), 플레이어(Player), 출구(Exit)가 존재해야 한다.

맵을 문자열 형태로 저장했기 때문에 각각의 요소가 문자열 안에 들어있는지 확인하면 된다.

게임 환경 설정

게임 화면 크기 계산

맵의 전체 크기에 따라 게임 화면도 같이 변하도록 계산했다.

이미지의 넓이에 맵의 가로, 세로 길이를 각각 곱하면 게임 화면 크기가 나온다.

예를 들어, 16x16 이미지를 사용했고, 가로가 5, 세로가 5인 맵이라면, 게임 화면의 가로와 세로는 모두 16x5인 정사각형 형태가 될 것이다.

이동위치 계산

방향키가 눌리면 다음에 이동할 위치에 캐릭터를 위치시키고, 기존에 있던 자리를 빈 공간으로 만들어야 한다.

예를 들면, 아래와 같이 조건문을 활용해서 입력한 키에 따라 이동할 위치를 계산할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# define KEY_W		13
# define KEY_A		0
# define KEY_S		1
# define KEY_D		2

void	control_key_release(int key_code)
{
	if (key_code == KEY_W)
	{
		map[player_idx - map_width] = 'P';	// 위로 이동한 자리에 플레이어를 위치
		map[player_idx] = '0';							// 원래 있던 자리는 빈 공간으로 설정
		player_idx -= map_width;						// 현재 인덱스에서 맵의 가로만큼 빼서 인덱스 재설정
	}
	// 이하 생략
	if (key_code == KEY_A)
	...
}

하지만, 상하좌우 모든 경우에 대해 계산을 하게 되면 조건문이 반복되면서 코드가 길어지는 단점이 있다.

그래서 각 방향이 이동할 거리를 배열에 저장하면 조건문을 사용하지 않고 쉽게 계산할 수 있다.

또한, 중복되는 코드를 줄였기 때문에 코드의 가독성이 높아진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# define KEY_W		13
# define KEY_A		0
# define KEY_S		1
# define KEY_D		2

size_t	offset[14];	// 사용하는 key code 값의 범위에 따라 배열의 크기를 초기화

void	init_offset()
{
	offset[KEY_W] = -map_width;	// 상
	offset[KEY_A] = -1;					// 좌
	offset[KEY_S] = map_width;	// 하
	offset[KEY_D] = 1;					// 우
}

void	control_key_release(int key_code)
{
	map[player_idx + offset[key_code] = 'P';	// 방향에 따라 이동한 곳에 플레이어 위치
	map[player_idx] = '0';										// 원래 있던 자리는 빈 공간으로 설정
	player_idx += offset[key_code];						// 현재 인덱스에서 이동한 방향에 따라 인덱스 재설정
}

게임 화면 그리기

저장된 맵을 윈도우에 표시하는 방법에는 두 가지를 떠올릴 수 있다.

  1. 문자열로 저장된 맵을 매번 반복해서 표시하기
  2. 고정된 요소(벽, 출구)는 한번만 그리고 나머지 변경된 사항만 업데이트

1번은 맵의 크기만큼 시간이 오래 걸리지만, 2번은 변경된 곳만 바꿔주기 때문에 1번보다 속도가 빠르다.

맵이 작다면 1번으로 구현해도 큰 상관은 없지만, 3D나 엄청 큰 맵이라면 2번이 적합하다.

하지만, 이번 과제는 2D 로 구현하는 것이기 때문에 간단하게 1번으로 구현했다.

맵 문자열에 대해 반복문을 돌면서 각각의 요소에 맞게 이미지를 표시하면 된다.

보너스

보너스는 자세한 설명 대신 접근법에 대해서만 간단히 소개합니다.

화면에 걸음 표시하기

mlx_put_string 을 사용하면 된다.

텍스트 컬러는 투명도를 포함한 TRGB 형태로 받는다.

1
2
0xFF000000
  TTRRGGBB

T가 FF에 가까울 수록 투명도가 0%에 가깝기 때문에 선명하다.

반대로 00이면 투명도가 100%에 가깝기 때문에 흐릿하게 보인다.

움직이는 적 구현하기

캐릭터와 적이 만나면 게임이 종료된다.

움직이는 적을 구현하기 위해 mlx_loop_hook 함수를 활용했다.

1
int	mlx_loop_hook (void *mlx_ptr, int (*funct_ptr)(), void *param);

이 함수를 실행시키면 밀리세컨드 단위로 함수 포인터로 넘기는 함수(funct_ptr)를 반복한다.

특정 함수를 무한 반복하는 함수라고 생각하면 된다.

매우 짧은 간격으로 함수가 반복되기 때문에 적을 움직이게 한다면 엄청 빠른 속도로 움직일 것이다.

mlx_loop_bad_example.gif

이를 해결하기 위해 FPS(Frame Per Second) 개념을 적용했다.

FPS는 1초에 몇 장의 사진이 보이는 지 의미한다.

참고로 24fps는 영화가 움직이는 것처럼 보이기에 적합한 프레임 수이다.

그래서 mlx_loop_hook 이라는 함수에 fps를 계속 계산하고, 특정 fps에만 적이 움직이는 간격을 설정해주면 된다.

mlx_loop_fps.gif

1
2
3
4
5
6
void	move_enemy()
{
	if (fps % 24 == 0)
		return ;
	draw_enemy();
}

애니메이션 구현하기

사실 이 부분은 구현하기 나름이라서 정말 게임처럼 부드럽게 구현할 수도 있다.

하지만 최대한 간단하게 구현하기 위해서 약간의 타협을 했다.

캐릭터가 생동감있게 움직이는 것을 표현하기 위해 각 움직임에 따른 이미지를 먼저 준비했다.

그 다음, 이미지의 개수만큼 이미지를 저장할 배열을 선언해서 저장한다.

1
char	*sprites[4];

현재 움직인 횟수를 이미지의 개수만큼 나눈 나머지를 구하여 현재 움직임에 따른 이미지를 표시한다.

1
mlx_put_image_to_window(mlx, win, sprites[moves % 4], WIDTH, HEIGHT);

so_long_sprite.gif

다소 끊기는 감이 있지만, 부드럽게 구현하는 것은 다음 과제에서 조금 더 심도있게 다루어보고자 한다.

회고

페어 프로그래밍

이번 과제는 다른 분과 함께 과제를 진행했다.

확실히 같이 하면 속도가 정말 빠르다.

스스로와 약속이 아닌 다른 사람과 일정을 만들어서 반강제적으로 과제를 하게 되는 효과가 있다.

다만, 페어를 할 때는 처음부터 모든 과정을 함께 공유해야 조금 더 효과가 있는 것 같다.

그래야만 어떤 부분에서 고민을 했고, 어떻게 문제를 해결했는지 경험할 수 있기 때문이다.

완성된 코드에는 문제 해결을 위한 과정보다는 문제 해결의 결과만 볼 수 있다.

게임에 버그가 많은 건 어쩔 수 없다

어릴 때부터 게임을 좋아해서 게임을 많이 해왔다.

게임 내에 사소한 버그가 있으면 게임사가 개발을 못한다고 생각을 했었다.

하지만, 이번에 간단한 게임을 개발하는데도 수 많은 버그를 만나면서 스스로 무지하고 어리석었다는 걸 느꼈다.

정말 완성도 높은 게임을 위해서는 상상 이상의 고민과 노력이 필요하다는 것을 느꼈다.

이전에는 시중에서 판매되는 게임의 가격이 비싸다고 생각했는데, 이제는 더 이상 그렇지 못하다.

게임을 위해 쏟은 노력과 고민은 감히 헤아릴 수 없을 것 같다.

This post is licensed under CC BY 4.0 by the author.