본문 바로가기

프로그래밍

[42 서울] so_long을 빠르게 끝내보자

서론

push_swap을 끝내고 참여한 사이드 프로젝트가 끝나가면서 여유도 조금 생긴 것도 있지만, 당장 이번 주에 과제를 안내면 월급을 못 받을지도 모르는 상황에 처해서 급하게 과제를 하나 해치웠다. 내가 이전에 so long을 하고 다른 과제를 남겨뒀다면 큰일이었을 텐데 다행히 난이도가 상대적으로 낮은 과제여서 제대로 잡은 지 3~4일 만에 해결을 할 수 있었다. 

 

서브젝트 정리

이번 평가부터 평가표를 정리하지 않기로 했다. 원래 뭘 해야 하는지 생각하는 게 좋다고 생각했는데, 최근의 논란들에서 평가표를 미리 보는 행위가 과제에는 도움이 되지만 나의 실패 경험을 쌓는 데는 도움이 안 된다는 걸 알게 되었다. 그래서 과제 2개 만에 다시 방법을 바꾸기로 했음.

 

miniLibX를 사용하여 최소한의 움직임으로 모든 수집품을 모으고 탈출하는 2d게임을 만드는 것이 목표이다. 최근 게임 중엔 헬테이커가 가장 비슷하다.

 

예시로는 돌고래가 생선을 먹고 지구를 탈출하는 내용을 담고 있어서 과제의 제목이 안녕, 그리고 물고기들은 고마웠어요 (So long, and thanks for all the fish)에서 따왔음을 알 수 있다. 나는 처음에 so long 이라는 제목과 게임이라는 주제를 보고 https://youtu.be/OCh2l0J1uJk 에서 따온 줄 알았으나 아니었다. 

 

open, close, read, write, malloc, free, perror, strerror, exit, 그리고 MinilibX 라이브러리 내의 모든 함수들을 사용할 수 있다. 당연하지만 메모리 누수와 에러는 있어선 안된다.

wasd를 이용하여 주인공을 조작하고 ESC를 누르거나 창 상단의 종료 버튼을 누르면 종료되어야 한다.

작업창 관리는 부드러워야 한다(창 최소화, 다른 창으로 전환 등의 동작)

현재까지 움직인 횟수가 쉘에 출력되어야 한다.

. ber 형식의 맵을 인자로 받는 프로그램을 만들고 해당 지도는 0은 빈 공간, 1은 벽, C는 수집품, E는 맵의 출구, P는 주인공의 시작 지점 이렇게 5개의 문자열로 구성되어있다.

1111111111111
10010000000C1
1000011111001
1P0011E000001
1111111111111

1111111111111111111111111111111111
1E0000000000000C00000C000000000001
1010010100100000101001000000010101
1010010010101010001001000000010101
1P0000000C00C0000000000000000000C1
1111111111111111111111111111111111

예시처럼 전체가 벽으로 둘러싸여 있어야 하며 출구, 수집품, 시작 지점은 반드시 하나 이상 있어야 한다. 만약 맵에 문제가 있다면 ”Error\n”와 직접 정한 에러 메시지를 출력하며 종료되어야 한다. 출구로 가는 길은 검증하지 않아도 된다.

 

과제를 처음 봤을 때의 궁금증들

miniLibX란 무엇인가

 - 작은 그래픽 라이브러리. 그래픽에 대한 지식 없이도 스크린에 무언가를 띄울 수 있는(렌더링) 라이브러리이다.

작업창 관리는 부드럽게 동작해야 한다는 게 무슨 뜻인가

 - 결국 끝까지 알지 못한 채 종결. 기본적으로 지원해주는 것 같음

움직임의 정의(벽에 가져다 박는 경우 횟수가 늘어나는가)

 - 비슷한 게임인 헬테이커를 해보니 벽에 가져다 박는 건 횟수가 늘어나지 않았음 그렇게 구현해야 할 듯

wasd와 esc 그리고 창 상단의 종료 버튼에 기능을 어떻게 부여하는가

 - miniLibX에 key를 hook 할 수 있는 기능이 있다. 해당 키에 기능을 부여하면 됨

minilibX의 images를 사용하는 것을 강력히 추천이 무슨 뜻인가

 - 이미지 객체를 바로 불러올 수 있도록 해주는 라이브러리이기 때문에 과제 진행이 빨라지고 원하는 대로 만들 수 있게 됨

. ber 확장자는 뭔가

 - 확장자의 종류가 특별히 중요한 건 아닌 듯

어떻게 파일을 지도로 변환할 수 있는가

 - gnl을 이용해서 파일을 읽은 다음 적절하게 변환해서

xpm 파일이란?

 - X windows에서 사용되는 비트맵 이미지, 그냥 png 가져다가 변환해서 쓰면 되는 모양

 

과업 단위로 나누기

  • 라이브러리 설치해서 창 띄우기
  • 키 hook
  • images 사용해서 이미지 넣어보기
  • 파일 가져와서 맵 만들기
  • 게임처럼 동작하도록 만들기
  • 에러 처리
  • 평가 준비

 

라이브러리 설치 및 창 띄우기

서브젝트에서 제공해주는 라이브러리를 사용하면 된다. 처음에 좀 헤맨게 집 환경이 m1이라서 제대로 컴파일이 안 되는 이슈가 있었다.

 

집에서는 makefile에 arch -x86_64 를 넣어서 해결했고 클러스터에서 해당 부분을 뺐다.

 

라이브러리를 과제 폴더에 mlx 폴더를 만들어서 그 안에 넣어주면 된다. mlx 폴더에 들어가서 arch -x86_64 make 를 미리 한번 해서 목적 파일을 만들어준다.

all:
	arch -x86_64 gcc -L./mlx -lmlx -framework OpenGL -framework AppKit main.c
	./a.out

 

#include "./mlx/mlx.h"

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

	mlx_ptr = mlx_init();
	win_ptr = mlx_new_window(mlx_ptr, 500, 500, "mlx 42");
	mlx_loop(mlx_ptr);
}

이렇게 넣어서 잘 실행되는지를 먼저 확인한다

 

void * mlx_init(void)

    - 나의 소프트웨어와 OS의 디스플레이를 연결해주는 함수.

 

void * mlx_new_window ( void mlx_ptr, int size_x, int size_y, char *title );

    - 디스플레이에 새로운 윈도우를 띄우는 함수. 앞서 받아온 포인터와 가로 세로 크기, 그리고 창의 제목을 받아서 띄운다

 

int mlx_loop ( void *mlx_ptr );

    - 띄운 창에서 키보드와 마우스의 입력을 기다린다. 혹은 창의 일부를 다시 그리는 역할도 함

 

키 hook

# define X_EVENT_KEY_PRESS			2
# define X_EVENT_KEY_RELEASE		3

# define KEY_ESC		53
# define KEY_W			13
# define KEY_A			0
# define KEY_S			1
# define KEY_D			2

typedef struct s_param{
	int		x;
	int		y;
}				t_param;

void			param_init(t_param *param)
{
	param->x = 3;
	param->y = 4;
}

int				key_press(int keycode, t_param *param)
{
	static int a = 0;

	if (keycode == KEY_W)
		param->y++;
	else if (keycode == KEY_S)
		param->y--;
	else if (keycode == KEY_A)
		param->x--;
	else if (keycode == KEY_D)
		param->x++;
	else if (keycode == KEY_ESC)
		exit(0);
	printf("x: %d, y: %d\n", param->x, param->y);
	return (0);
}

int			main(void)
{
	void		*mlx;
	void		*win;
	t_param		param;

	param_init(&param);
	mlx = mlx_init();
	win = mlx_new_window(mlx, 500, 500, "mlx_project");
	mlx_hook(win, X_EVENT_KEY_RELEASE, 0, &key_press, &param);
	mlx_loop(mlx);
}

함수 포인터로 호출된 함수에서 눌린 키를 바로 알 수 있기 때문에 의외로 쉽게 코딩할 수 있다.

 

int mlx_hook(void *win_ptr, int x_event, int x_mask, int (*func)(), void *param)

https://harm-smits.github.io/42docs/libs/minilibx/events.html#x11-events

    - 윈도우 식별자, X11 event(링크 첨부), x_mask(mac에선 미사용으로 0 입력), 호출할 함수 포인터(눌린 키와 창에서 눌린 좌표 등이 전달됨), 함수에 전달할 파라미터를 인자로 받는 함수

 

 

images 사용해서 이미지 넣어보기

int main()
{
	void *mlx;
	void *win;
	void *img;
	void *img2;
	void *img3;
	void *img4;
	void *img5;
	void *img6;
	void *img7;
	int img_width;
	int img_height;

	mlx = mlx_init();
	win = mlx_new_window(mlx, 500, 500, "my_mlx");
	img = mlx_xpm_file_to_image(mlx, "./images/land.xpm", &img_width, &img_height);
	img2 = mlx_xpm_file_to_image(mlx, "./images/wall.xpm", &img_width, &img_height);
	img3 = mlx_xpm_file_to_image(mlx, "./images/chara.xpm", &img_width, &img_height);
	img4 = mlx_xpm_file_to_image(mlx, "./images/chest.xpm", &img_width, &img_height);
	img5 = mlx_xpm_file_to_image(mlx, "./images/chest_open.xpm", &img_width, &img_height);
	img6 = mlx_xpm_file_to_image(mlx, "./images/rune.xpm", &img_width, &img_height);
	img7 = mlx_xpm_file_to_image(mlx, "./images/rune_light.xpm", &img_width, &img_height);
	mlx_put_image_to_window(mlx, win, img, 0, 0);
	mlx_put_image_to_window(mlx, win, img2, 64, 0);
	mlx_put_image_to_window(mlx, win, img3, 128, 0);
	mlx_put_image_to_window(mlx, win, img4, 192, 64);
	mlx_put_image_to_window(mlx, win, img5, 0, 64);
	mlx_put_image_to_window(mlx, win, img6, 64, 64);
	mlx_put_image_to_window(mlx, win, img7, 128, 64);
	mlx_loop(mlx);
	return (0);
}

적당한 png파일을 구해서 https://anyconv.com/ko/png-to-xpm-byeonhwangi/ 에서 변환해서 사용하면 된다.

 

서브젝트에 있는 링크 https://itch.io/game-assets/free/tag-sprites 에서 32비트 혹은 64비트 크기의 png 파일을 사용하면 된다.

 

void * mlx_xpm_file_to_image(void *mlx_ptr, char *filename, int *width, int *height)

    - mlx포인터, 파일 주소, 가로 세로 크기를 가져와서 메모리에 올리고 해당 메모리의 주소를 반환한다

 

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

    - 이미지를 받아서 띄울 포인터들을 인자로 받고 윈도우 안에서의 좌표를 지정해서 해당 윈도우에 띄워준다

 

파일 가져와서 맵 만들기

다른 팀원들처럼 이차원 배열로 만들고 플레이어의 위치를 저장하는 구조체를 만들려고 했다가 다시 생각해보니 굳이 그럴 필요가 없다는 걸 알게 되었다.

1111111111111
10010000000C1
1000011111001
1P0011E000001
1111111111111

맵의 형태가 이렇게 생겼다는 것이 보장되므로, (정확히 말하면 이러한 형태가 아니면 에러로 처리했을 테니) 처음에 가로 크기가 13이라는 것을 변수에 담아두면 컴퓨터 상에서 0~12는 벽이고 13은 벽, 14,15는 빈 공간, 16은 벽... 25는 수집품... 이런 식으로 알 수 있을 것이다.

 

void	map_read(char *filename, t_game *game)
{
	int  fd;
	char *line;

	fd = open(filename, O_RDONLY);
	line = get_next_line(fd);
	game->hei = 0;
	game->wid = ft_strlen(line) - 1;
	game->str_line = ft_strdup_without_newline(line);
	free(line);
	while (line)
	{
		game->hei++;
		line = get_next_line(fd);
		if (line)
		{
			game->str_line = ft_strjoin_without_newline(game->str_line, line);
		}
	}
	close(fd);
	printf("%s\n", game->str_line);
}

 

이제 가져온 문자열에 아까 가져온 이미지를 더해서 맵을 만들어준다

void	setting_img(t_game game)
{
	int		hei;
	int		wid;

	hei = 0;
	while (hei < game.height)
	{
		wid = 0;
		while (wid < game.width)
		{
			if (game.str_line[hei * game.width + wid] == '1')
			{
				mlx_put_image_to_window(game.mlx, game.win, game.img.wall, wid * 64, hei * 64);
			}
			else if (game.str_line[hei * game.width + wid] == 'C')
			{
				mlx_put_image_to_window(game.mlx, game.win, game.img.chest, wid * 64, hei * 64);
			}
			else if (game.str_line[hei * game.width + wid] == 'P')
			{
				mlx_put_image_to_window(game.mlx, game.win, game.img.chara, wid * 64, hei * 64);
			}
			else if (game.str_line[hei * game.width + wid] == 'E')
			{
				mlx_put_image_to_window(game.mlx, game.win, game.img.rune, wid * 64, hei * 64);
			}
			else
			{
				mlx_put_image_to_window(game.mlx, game.win, game.img.land, wid * 64, hei * 64);
			}
			wid++;
		}
		hei++;
	}
}

여기까지 했으면 거의 다 한 것이고 나머지도 크게 어렵지 않다

 

게임처럼 동작하도록 만들기

int	press_key(int key_code, t_game *game)
{
	if (key_code == KEY_ESC)
		exit_game(game);
	if (key_code == KEY_W)
		move_w(game);
	if (key_code == KEY_A)
		move_a(game);
	if (key_code == KEY_S)
		move_s(game);
	if (key_code == KEY_D)
		move_d(game);
	return (0);
}

void	move_w(t_game *g)
{
	int	i;

	i = 0;
	while (i++ < ft_strlen(g->str_line))
	{
		if (g->str_line[i] == 'P')
			break ;
	}
	if (g->str_line[i - g->wid] == 'C')
		g->col_cnt++;
	if (g->str_line[i - g->wid] == 'E' && g->all_col == g->col_cnt)
		clear_game(g);
	else if (g->str_line[i - g->wid] != '1' && g->str_line[i - g->wid] != 'E')
	{
		g->str_line[i] = '0';
		g->str_line[i - g->wid] = 'P';
		g->walk_cnt++;
		printf("%d\n", g->walk_cnt);
		setting_img(g);
	}
}

구현해야 할 것을 요약하면 wasd를 눌렀을 때 적절히 움직이는 것이다.

 

추가로 벽을 향해 움직이면 움직이지 않고, 수집품을 다 먹고 출구를 가면 클리어하는 것 등을 구현하면 된다.

 

움직일 때마다 이미지를 다시 띄워주면서 자연스럽게 움직이도록 만들면 된다.

 

에러 처리

1. 파일명 에러

if (ac != 2)
	print_err("Map is missing.\n");
        
fd = open(filename, O_RDONLY);
	if (fd <= 0)
		print_err("File open fail.\n");

  메인 문과 map_read 함수에서 처리해준다

 

2. 지도가 직사각형이 아닌 경우

if (game->height * game->width != ft_strlen(game->str_line))
	print_err("Map must be rectangular.\n");

  맵의 가로 크기와 세로 크기를 저장해둿기때문에 둘의 곱과 맵의 글자 수가 다르면 직사각형이 아니라는 걸 알 수 있다.

 

3. 지도가 벽으로 둘러싸여 있지 않을 경우

int i = 0;

while (i < ft_strlen(game->str_line))
{
	if (i < game->width)
	{
		if (game->str_line[i] != '1')
			print_err("Map must be closed/surrounded by walls");
	}
	else if (i % game->width == 0 || i % game->width == game->width - 1)
	{
		if (game->str_line[i] != '1')
			print_err("Map must be closed/surrounded by walls");
	}
	else if (i > ft_strlen(game->str_line) - game->width)
	{
		if (game->str_line[i] != '1')
			print_err("Map must be closed/surrounded by walls");
	}
	i++;
}

  첫 줄과 중간줄 마지막줄의 로직을 분리해서, 첫줄 먼저 1인걸 확인한다.

  첫 줄이 전부 1인걸 확인하고 중간줄로 넘어가서 중간줄의 첫 번째 글자와 마지막 글자를 확인해서 1인걸 확인한다(직사각형 체크할 때 확인한 가로 크기를 이용)

  마지막 줄까지 확인한 다음 마지막 줄을 다시 가져와서 1인걸 확인한다

 

4. 맵에 출구/시작 지점/수집품이 없는 경우

	int	i;
	int	num_e;
	int	num_p;
	int	num_c;

	i = 0;
	num_e = 0;
	num_p = 0;
	num_c = 0;
	while (i++ < ft_strlen(game->str_line))
	{
		if (game->str_line[i] == 'E')
			num_e++;
		else if (game->str_line[i] == 'P')
			num_p++;
		else if (game->str_line[i] == 'C')
			num_c++;
	}
	if (num_e == 0)
		print_err("Map must have at least one exit");
	if (num_c == 0)
		print_err("Map must have at least one collectible");
	if (num_p != 1)
		print_err("Map must have one starting position");

  맵에 있는 개수를 저장해서 확인하고 error로 처리, 플레이어의 위치는 한 개만 있는 것을 정상적이라고 판단했다. 이건 의견이 갈릴 수도 있을 듯

 

5. 상단의 x버튼 누르면 프로세스 종료

mlx_hook(game->win, X_EVENT_KEY_EXIT, 0, &exit_game, game);

int	exit_game(t_game *game)
{
	mlx_destroy_window(game->mlx, game->win);
	exit(0);
}

  사실 mlx_destroy_window는 안 써도 크게 상관없는데, 함수 특성상 반드시 인자가 필요해서 인자를 받아서 사용하기 위해 살짝 억지로 넣었다. 

 

결론

https://youtu.be/wlTbmT_DeKA

 

만든 걸 동작시켜보면 이렇게 나온다. 서브젝트에는 없지만 사소한 디테일로 상자를 다 먹으면 출구에 불이 들어오도록 해뒀는데 심심하지 않게 잘 만든 것 같다.

 

so_long 은 난이도가 별로 높지 않아서 그런지 처음부터 끝까지 구현하는 가이드가 없어서 하나 작성해봤다. 그런데 계속 가이드를 쓰다 보니 앞으로 하는 과제도 계속 써야 할 것 같은 의무감이 생기는 것 같다. 이건 나중에 다시 생각해봐야지