XR 플밍 - 1. 프로그램 언어 기초 (6) - 콘솔 프로젝트 1 - 미로게임 (3/18)

이형원·2025년 3월 18일
0

XR플밍

목록 보기
14/215

지금까지 배운 내용을 바탕으로 콘솔 텍스트 게임(머드 게임)을 만들어보자.

0. 가볍게 알아보자 - 머드 게임이란?

1978년에 만들어진 MUD(Multi-User Dungeon)이라는 게임을 기반으로 확장된 장르이다.
간단히 말해서 텍스트 기반의 멀티 유저 게임으로, 던전을 탐험하는 내용으로 시작했다.
그래픽에 대한 구현 없이 콘솔로 텍스트만을 출력해 게임을 구현하는 것이 특징이다.

1. 게임이란?

지금까지는 프로그래밍에 대한 공부에 치중하고 있었지만, 게임 프로그래머로서의 준비를 하는 과정이니만큼 게임이란 것이 무엇인지 잘 알아야 할 필요가 있다고 느꼈다.
가볍게 게임의 정의가 무엇인지 알아보자.

1.1) 게임의 정의

게임은 영화, 드라마, 소설 등 다른 문학 및 예술 장르와 달리 '상호작용'에 중점을 둔 장르이다.
따라서 게임으로 정의되는 것들은,
progress input -> update game -> render (입력 -> 처리 -> 결과) 의 과정을 거친다.

1.2) 머드 게임의 실행 단계

위와 같은 과정을 거쳐 게임을 실행하기 위해서는 어떻게 해야 할까?
게임의 구체적인 내용을 작성하기 이전, 게임의 루프 단계를 간단하게 정리해 보았다.

bool gameOver = false;		// 게임 종료의 판단 값

Start();	// 시작

while(gameOver == false)	// 게임 진행동안 무한반복(진행)
{
    Render();	// 출력
    Input();	// 입력
    Update();	// 처리
}

End();		// 종료

여기서 콘솔 텍스트 게임 제작만에서의 특이점이 있다.
앞으로 Unity 등의 게임 개발 과정에서는 게임의 진행 과정은 입력 -> 처리 -> 출력의 과정으로 진행되겠지만, 콘솔 텍스트 게임의 경우에는 초기화면의 출력이 먼지 진행되어야 한다.
그러므로 콘솔 텍스트 게임 한정으로 출력 -> 입력 -> 처리 순서로 게임이 진행되도록 한다.

2. 미로 게임 제작

2.1) 구조체와 플레이어 위치 선언

bool gameOver = false;		// 게임 종료의 판단 값

Start();	// 시작

while(gameOver == false)	// 게임 진행동안 무한반복(진행)
{
    Render();	// 출력
    Input();	// 입력
    Update();	// 처리
}

End();		// 종료

위와 같은 기본 틀에서, 우선 좌표를 표시할 구조체를 만들자.

struct Position
{
	public int x;
	public int y;
}

앞으로 위치 구조체를 이용하여 좌표를 표시할 것이다.
플레이어의 위치를 메인에서 초기화하고, Start 함수에서 위치를 설정한다.

internal class Program
{
    struct Position
    {
        public int x;
        public int y;
    }

    static void Main(string[] args)
    {
        bool gameOver = false;
        Position playerPos;
        playerPos.x = 0;
        playerPos.y = 0;

        Start(ref playerPos);
        while(gameOver == false)
        {
            Render();	// 출력
    		Input();	// 입력
    		Update();	// 처리
        }
        End();            
    }

    static void Start(ref Position playerPos)
    {
        // 플레이어 초기 위치 설정
        playerPos.x = 5;
        playerPos.y = 5;
    }
}

이때 플레이어의 위치를 참조로 반영하여야 하므로 ref를 붙여 Position을 반환하도록 한다.

2.2) 플레이어의 출력 및 이동 구현

먼저 플레이어의 출력을 해 보자. Start문으로 플레이어의 좌표가 선언되어 있으니 아래와 같이 플레이어의 출력과 동시에 움직임을 구현해보자.

...

static void Main(string[] args)
{
    bool gameOver = false;
    Position playerPos;
    playerPos.x = 0;
    playerPos.y = 0;

    Start(ref playerPos);
    while (gameOver == false)
    {
        Render(playerPos);
        ConsoleKey key = Input();
        Update(key, ref playerPos);
    }
    End();
}

...

static void Render(Position playerPos)
{
    // 플레이어 위치로 커서 옮기기
    Console.SetCursorPosition(playerPos.x, playerPos.y);
    // 플레이어 출력
    Console.Write('P');
}

static ConsoleKey Input()
{
    // 입력 작업
    return Console.ReadKey(true).Key;
}

static void Update(ConsoleKey key, ref Position playerPos)
{
    // 처리작업
    switch (key)
    {
        case ConsoleKey.A:
        case ConsoleKey.LeftArrow:
            playerPos.x--;
            break;

        case ConsoleKey.D:
        case ConsoleKey.RightArrow:
            playerPos.x++;
            break;

        case ConsoleKey.W:
        case ConsoleKey.UpArrow:
            playerPos.y--;
            break;

        case ConsoleKey.S:
        case ConsoleKey.DownArrow:
            playerPos.y++;
            break;
    }
}
...

여기에서 주의해야 할 점은 플레이어의 움직임을 구현하는 데에 좌표가 일반적인 상황과 다르다는 것이다.
앞으로 유니티 같은 게임은 일반적으로 생각하는 데카르트 좌표계를 사용하지만, 콘솔 입력 같은 경우는 텍스트 입력을 위해 만들어진 것이다 보니, 최초 (0, 0) 좌표가 좌측 상단이라 생각하면 편하다.
그러므로 y축 이동방향을 반대로 설정해야 하는 점을 숙지해야 한다.

위와 같이 작성하고 출력하면 아래와 같이 출력된다.
플레이어가 출력되긴 하지만 그 지나간 흔적이 그대로 남아버리는 것을 확인할 수가 있다. 이를 해결할 수 있는 방법은 크게 두 가지가 있다.

  1. Console.Clear();를 이용해 플레이어의 움직임을 계속 지워준다. // 사용시 깜빡거림(백버퍼) 발생
  2. Console.SetCursorPosition(0, 0); 로 출력된 플레이어 이미지 좌표를 계속 수정해준다.

여기서 두 번째 방법을 사용하기로 한다.
추가로 플레이어 우측에 커서가 깜빡이는 게 거슬릴 수도 있으니, 커서를 보이지 않게 Console.CursorVisible = false;로 Start에 선언한다.

  • 중간 단계 코드
internal class Program
{
    struct Position
    {
        public int x;
        public int y;
    }

    static void Main(string[] args)
    {
        bool gameOver = false;
        Position playerPos;
        playerPos.x = 0;
        playerPos.y = 0;

        Start(ref playerPos);
        while(gameOver == false)
        {
            Render(playerPos);
            ConsoleKey key = Input();
            Update(key, ref playerPos);
        }
        End();            
    }

    static void Start(ref Position playerPos)
    {
    	// 게임 설정
		Console.CursorVisible = false;
        // 플레이어 초기 위치 설정
        playerPos.x = 5;
        playerPos.y = 5;
    }    

    static void Render(Position playerPos)
    {    
        Console.SetCursorPosition(0, 0);
        // 플레이어 위치로 커서 옮기기
        Console.SetCursorPosition(playerPos.x, playerPos.y);
        // 플레이어 출력
        Console.ForegroundColor = ConsoleColor.Green; 	//플리이어 컬러 초록색으로
		Console.Write('P');
		Console.ResetColor();							//컬러 초기화
    }

    static ConsoleKey Input()
    {
        // 입력 작업
        return Console.ReadKey(true).Key;
    }

    static void Update(ConsoleKey key, ref Position playerPos)
    {
        // 처리작업
        switch (key)
        {
            case ConsoleKey.A:
            case ConsoleKey.LeftArrow:
                playerPos.x--;
                break;

            case ConsoleKey.D:
            case ConsoleKey.RightArrow:
                playerPos.x++;
                break;

            case ConsoleKey.W:
            case ConsoleKey.UpArrow:
                playerPos.y--;
                break;

            case ConsoleKey.S:
            case ConsoleKey.DownArrow:
                playerPos.y++;
                break;
        }
    }

    static void End()
    {
        // 게임 종료
    }
}

2.3) 맵 구성하기

플레이어를 구현하였으니, 다음으론 맵을 디자인해보도록 하자.

  • 미로를 만들어야 하니, 우선 칸 수는 가로 18 * 세로 10 길이의 사각형의 벽을 출력한다.
  • 벽은 플레이어가 넘을 수 없게 설정한다.

이를 작성하기 위해서 2차원 배열을 활용할 것이다. 우선 Main에 bool[,] map; 을 선언하고 Start문에서 맵을 작성하자.

 static void Start(ref Position playerPos, out bool[,] map)
 {
     // 게임 설정
     Console.CursorVisible = false;

     // 플레이어 초기 위치 설정
     playerPos.x = 5;
     playerPos.y = 5;

     // 맵 설정하기
     map = new bool[10, 15] // false는 통과할 수 없는 벽, true는 통과할 수 있는 공간으로 표시
     {		
		{ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false },
		{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
		{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
 		{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
   		{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
    	{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
    	{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
    	{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
    	{ false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
    	{ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false }
     };     
 }

이와 같이 맵을 작성하고 Render에 맵을 출력하는 코드 또한 작성한다.

static void Render(Position playerPos, bool[,] map)
{
    Console.SetCursorPosition(0, 0); 
	for (int y = 0; y < map.GetLength(0); y++)
	{
    	for (int x = 0; x < map.GetLength(1); x++)
    	{
        	if (map[y, x] == false)
        	{
            	Console.Write("#");
        	}
        	else
        	{
            	Console.Write(" ");
        	}
    	}
    	Console.WriteLine();
	}
}

...

이와 같이 작성하고 출력하면 다음과 같이 출력된다.
다만 이는 아직 벽을 통과하지 못하게 하는 조건을 반영하지 않은 상태이므로, 지금 상태면 그대로 벽을 통과할 것이다.
따라서 작성했던 플레이어 움직임 부분으로 돌아가 코드를 수정한다.

static void Update(ConsoleKey key, ref Position playerPos, bool[,] map)
{
    // 처리작업
    switch (key)
    {
        case ConsoleKey.A:
        case ConsoleKey.LeftArrow:
            if (map[playerPos.y, playerPos.x - 1] == true)
            {
                playerPos.x--;
            }
            break;

        case ConsoleKey.D:
        case ConsoleKey.RightArrow:
            if (map[playerPos.y, playerPos.x + 1] == true)
            {
                playerPos.x++;
            }
            break;

        case ConsoleKey.W:
        case ConsoleKey.UpArrow:
            if (map[playerPos.y - 1, playerPos.x] == true)
            {
                playerPos.y--;
            }
            break;

        case ConsoleKey.S:
        case ConsoleKey.DownArrow:
            if (map[playerPos.y + 1, playerPos.x] == true)
            {
                playerPos.y++;
            }
            break;
    }
}

2.4) 함수 쪼개기

가능하면 함수를 더 작은 함수로 쪼개기보다는 기능별로 함수를 담아서 정리하는 방식으로 하자.
(지금은 틀을 지키기 위해 Render 함수를 그대로 두었지만, 변수도 많아지는 상황에서 함수를 더 쪼개는 건 복잡해지기만 할 것 같으니, 기능 별로 정리하도록 하자)
이번에는 함수를 쪼개보는 것으로 코드를 더 가독성 있게 만드는 법을 알아볼 것이며, Render 함수를 쪼개 보자.

  • Render 함수 - 맵을 출력하는 함수와 플레이어를 출력하는 함수를 분리하였다.
static void Render(Position playerPos, bool[,] map)
{
    // 출력 작업 - 백버퍼
    // 콘솔 지우기
    //Console.Clear();
    Console.SetCursorPosition(0, 0);
    PrintMap(map);
    PrintPlayer(playerPos);
}

static void PrintMap(bool[,] map)
{
    // 맵 출력
    for (int y = 0; y < map.GetLength(0); y++)
    {
        for (int x = 0; x < map.GetLength(1); x++)
        {
            if (map[y, x] == false)
            {
                Console.Write("#");
            }
            else
            {
                Console.Write(" ");
            }
        }
        Console.WriteLine();
    }
}

static void PrintPlayer(Position playerPos)
{
    // 플레이어 위치로 커서 옮기기
    Console.SetCursorPosition(playerPos.x, playerPos.y);
    // 플레이어 출력
    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write('P');
    Console.ResetColor();
}

2.5) 골인지점 구현 및 승리와 게임종료 구현하기

이제 게임에 필요한 왠만한 요소는 다 갖춰졌다. 남은 건 게임의 승리 조건을 설정하고, 게임에서 승리했을 때 게임 종료를 선언하는 것이다.

이에 따라 필요한 것은 다음과 같다.

  1. 골인지점의 좌표를 초기화 및 설정하고, 출력한다.
  2. 골인지점에 플레이어가 도달했을 때 승리했다는 조건을 만들어준다.

1번의 경우는 코드의 반복으로 내용이 길어지는 탓에 생략하고, 마지막 부분에 전체 코드를 붙이도록 하겠다.
2의 경우를 구현해보자. 해당 부분은 함수를 선언하여 다음과 같이 작성하여, Update 문에 반영하는 식으로 작성하였다.

static void Update(ConsoleKey key, ref Position playerPos, Position goalPos, bool[,] map, ref bool gameOver)
{
    // 처리작업
    Move(key, ref playerPos, map);	// 플레이어 움직임
    bool isClear = CheckGameClear(playerPos, goalPos);
    if (isClear)
    {
        gameOver = true;
    }
}  
  
...
  
static bool CheckGameClear(Position playePos, Position goalPos)
{
    bool success = (playePos.x == goalPos.x) && (playePos.y == goalPos.y);
    return success;
}

이와 같이 작성하고 마지막에 End 부분에 축하멘트를 추가하였다.

static void End()
{
    // 게임 종료
    Console.Clear();
    Console.WriteLine("축하합니다!!! 미로 찾기에 성공하셨습니다!");
}

2.6) 기타 추가 구현사항

게임은 완성되었다. 하지만 여기에 추가 구현사항으로 첫 시작시에 타이틀을 보여주는 화면을 구현하였다.

static void ShowTitle()
{
  Console.WriteLine("-----------------");
  Console.WriteLine(" 레전드 미로찾기 ");
  Console.WriteLine("-----------------");
  Console.WriteLine();
  Console.WriteLine("아무 키나 눌러서 시작하세요...");

  Console.ReadKey(true);		// 아무 키나 입력하면 true 반환
  Console.Clear();
}

3. 결론

3.1) 코드 전문

완성된 게임의 전체 코드는 아래와 같다.

namespace _250318_09.ConsoleProject
{
    internal class Program
    {
        struct Position
        {
            public int x;
            public int y;
        }

        static void Main(string[] args)
        {
            bool gameOver = false;
            Position playerPos;
            Position goalPos;
            playerPos.x = 0;
            playerPos.y = 0;

            bool[,] map;

            ShowTitle();
            Start(ref playerPos, out goalPos, out map);
            while (gameOver == false)
            {
                Render(playerPos, goalPos, map);
                ConsoleKey key = Input();
                Update(key, ref playerPos, goalPos, map, ref gameOver);
            }
            End();
        }

        static void Start(ref Position playerPos, out Position goalPos, out bool[,] map)
        {
            // 게임 설정
            Console.CursorVisible = false;

            // 플레이어 초기 위치 설정
            playerPos.x = 5;
            playerPos.y = 5;

            // 목적지 위치 설정하기
            goalPos.x = 8;
            goalPos.y = 1;

            // 맵 설정하기
            map = new bool[10, 15]
            {
                { false, false, false, false, false, false, false, false, false, false, false, false, false, false, false },
                { false, true, true, true, true, true, true, false, true, true, true, true, true, true, false},
                { false, true, true, true, true, true, true, false, false, false, false, false, false, true, false},
                { false, true, true, true, true, true, true, true, true, true, true, true, false, true, false},
                { false, true, true, true, true, true, true, true, false, false, false, false, false, true, false},
                { false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
                { false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
                { false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
                { false, true, true, true, true, true, true, true, true, true, true, true, true, true, false},
                { false, false, false, false, false, false, false, false, false, false, false, false, false, false, false }
            };  
        }

        static void ShowTitle()
        {
            Console.WriteLine("-----------------");
            Console.WriteLine(" 레전드 미로찾기 ");
            Console.WriteLine("-----------------");
            Console.WriteLine();
            Console.WriteLine("아무 키나 눌러서 시작하세요...");

            Console.ReadKey(true);
            Console.Clear();
        }

        static void Render(Position playerPos, Position goalPos, bool[,] map)
        {
            // 출력 작업 - 백버퍼
            // 콘솔 지우기
            //Console.Clear();
            Console.SetCursorPosition(0, 0); // clear 대신 이걸로 깜빡거림을 지울 수 있으나 텍스트가 남을 위험성 있음
            PrintMap(map);
            PrintPlayer(playerPos);
            PrintGoal(goalPos);
        }

        static void PrintMap(bool[,] map)
        {
            // 맵 출력
            for (int y = 0; y < map.GetLength(0); y++)
            {
                for (int x = 0; x < map.GetLength(1); x++)
                {
                    if (map[y, x] == false)
                    {
                        Console.Write("#");
                    }
                    else
                    {
                        Console.Write(" ");
                    }
                }
                Console.WriteLine();
            }
        }

        static void PrintPlayer(Position playerPos)
        {
            // 플레이어 위치로 커서 옮기기
            Console.SetCursorPosition(playerPos.x, playerPos.y);
            // 플레이어 출력
            Console.ForegroundColor = ConsoleColor.Green;
            Console.Write('P');
            Console.ResetColor();
        }

        static void PrintGoal(Position goalPos)
        {
            Console.SetCursorPosition(goalPos.x, goalPos.y);
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.Write("G");
            Console.ResetColor();
        }

        static ConsoleKey Input()
        {
            // 입력 작업
            return Console.ReadKey(true).Key;
        }

        static void Update(ConsoleKey key, ref Position playerPos, Position goalPos, bool[,] map, ref bool gameOver)
        {
            // 처리작업
            Move(key, ref playerPos, map);
            bool isClear = CheckGameClear(playerPos, goalPos);
            if (isClear)
            {
                gameOver = true;
            }
        }

        static void Move(ConsoleKey key, ref Position playerPos, bool[,] map)
        {
            switch (key)
            {
                case ConsoleKey.A:
                case ConsoleKey.LeftArrow:
                    if (map[playerPos.y, playerPos.x - 1] == true)
                    {
                        playerPos.x--;
                    }
                    break;

                case ConsoleKey.D:
                case ConsoleKey.RightArrow:
                    if (map[playerPos.y, playerPos.x + 1] == true)
                    {
                        playerPos.x++;
                    }
                    break;

                case ConsoleKey.W:
                case ConsoleKey.UpArrow:
                    if (map[playerPos.y - 1, playerPos.x] == true)
                    {
                        playerPos.y--;
                    }
                    break;

                case ConsoleKey.S:
                case ConsoleKey.DownArrow:
                    if (map[playerPos.y + 1, playerPos.x] == true)
                    {
                        playerPos.y++;
                    }
                    break;
            }
        }

        static bool CheckGameClear(Position playePos, Position goalPos)
        {
            bool success = (playePos.x == goalPos.x) && (playePos.y == goalPos.y);
            return success;
        }

        static void End()
        {
            // 게임 종료
            Console.Clear();
            Console.WriteLine("축하합니다!!! 미로 찾기에 성공하셨습니다!");
        }
    }
}

고작 미로 찾기라는 게임 하나를 만드는 데 190줄의 코드를 사용하였다.
게임 하나를 만든다는 게 참 쉽지 않았다.



미로를 직접 만들어보려다가 너무 힘들어서 관뒀다... 그래서 미완성이다
이와 같이 게임이 잘 구동되는 것을 확인하였다.

3.2) 결론

이번 프로젝트를 하면서 느끼고 배운 점들을 정리하려고 한다.

  1. 지금까지 배운 프로그래밍 기초 과정에서, 실제 게임에 적용되는 내용 및 기술이 많았다.
    기초를 다지고 게임에 해당 요소롤 적용하는 방식에 대한 고민이 필요하다는 걸 느꼈다.
  2. 게임을 만들고 설계하는 과정에서 많은 고민이 필요하다. 캐릭터 초기 위치 설정 및 버그가 발생하지 않도록 하기 위한 고민 등등. 많은 조건과 경우의 수에 대한 고려가 필요하다.
profile
게임 만들러 코딩 공부중

0개의 댓글