Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
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
Archives
Today
Total
관리 메뉴

시작은 0부터

14. Buffer 와 Window, 지렁이 게임 만들기(꼬리 만들기) 본문

C# 학습일지

14. Buffer 와 Window, 지렁이 게임 만들기(꼬리 만들기)

0base 2022. 7. 19. 23:49

  이번 수업때는 어릴적 구형 게임기에서 많이 해봤던 지렁이게임을 직접 만들어 보았다. 교수님이 설명하시면 그렇구나 하면서도 처음부터 혼자서 코드를 짜라고 하면 바로바로 구조가 생각나지 않고 어디서부터 해야할지 막막한 감이 오는 것이 아직 제대로 이해하지못했음을 스스로 체감하게 하는 것 같다. 화면 안에서 플레이어가 입력받은 방향키에 따라 움직이고 아이템을 먹는 구조 까지는 교수님께서 직접 코드 설계를 보여주시면서 진행했다. 이후 그 코드를 가지고 아이템을 먹을 때마다 플레이어를 뒤따라오는 꼬리를 생성하는 것을 과제로 내주셨다. 이전 시간에 진행했던 슈팅게임보다 쉽다고 하셨는데 전혀 모르겠다.

 

클래스 구조는 슈팅게임과 거의 흡사하다. 

 

1.GameLoop 클래스

internal class GameLoop
    {
        public const int BOARD_WIDTH = 60;
        public const int BOARD_HEIGHT = 30;

        int score = 0;
        Player player = null;
        Item item = null;
        int oldTime = 0;
        int curSpeed = 100;

        bool gameOver = false;
        bool IsNeedRender = true;


        public void Awake() //초기 설정
        {
            Console.BufferWidth = Console.WindowWidth = BOARD_WIDTH;
            Console.BufferHeight = Console.WindowHeight = BOARD_HEIGHT;
            Console.CursorVisible = false;
            player = new Player();
            item = new Item();

        }

        public void Start() // 처음 한번만 실행
        {
            player.Start();

            item.RefreshPos();

        }

        public void Update() // 계속 갱신
        {
            if (gameOver == true)
                return;

            int curTime = System.Environment.TickCount & Int32.MaxValue;
            if (curTime - oldTime > curSpeed)
            {
                player.Update();
                oldTime = curTime;
            }

            if (player.GetPosX() == item.GetPosX() && //충돌체크
                player.GetPosY() == item.GetPosY())
            {
                score += 10; //점수 증가
                player.Count(); // 꼬리 개수 카운트

                item.RefreshPos(); //아이템 위치 재생성

                //curSpeed -= 10;
            }

            if (Console.KeyAvailable)
            {
                ConsoleKeyInfo cki = Console.ReadKey(true);
                switch (cki.Key)
                {
                    case ConsoleKey.LeftArrow:
                        player.Move_Left();
                        break;
                    case ConsoleKey.RightArrow:
                        player.Move_Right();
                        break;
                    case ConsoleKey.UpArrow:
                        player.Move_Up();
                        break;
                    case ConsoleKey.DownArrow:
                        player.Move_Down();
                        break;
                }
            }
            IsNeedRender = true;
        }
        public void Render()
        {
            if (IsNeedRender == false)
                return;

            Console.Clear();

            player.Render();

            item.Render();

            Console.SetCursorPosition(0, 0);
            Console.ForegroundColor = ConsoleColor.White;
            Console.Write("SCORE : {0}", score);

            if (gameOver == true)
            {
                Console.SetCursorPosition(BOARD_HEIGHT / 2, BOARD_WIDTH / 2);
                Console.WriteLine("Game Over");
            }

            IsNeedRender = false;
        }
    }

Buffer 와 Window

:  콘솔창의 크기를 설정할 때, 버퍼의 크기와 창의 크기를 같이 설정해주어야한다. Buffer 는 화면에 출력하는 범위를 나타내고 Window는 말그대로 콘솔'창'의 크기이다. 창의 크기보다 버퍼가 크다면 출력범위가 창의 현재 크기보다 크기 때문에 스크롤이 생기게 된다. 그래서 초기값을 설정하는 Awake 함수에서 버퍼의 크기와 윈도우의 크기를 맞춰주어야 콘솔화면이 스크롤없이 깔끔하게 생긴다.

 

이번 시간의 딜레이 방식은 이전의 딜레이 방식과 약간 다르다. 이전에는 현재시간에 이전시간을 빼서 그 값이 딜레이값보다 작으면 Continue를 이용해서 계속 되돌려보내서 딜레이값을 충족할 때까지 미뤘다면, 이번에는 딜레이값보다 크면 갱신하고 이전시간에 현재시간을 대입한다. 이전 공식과 다른건 사실 조건문이 딜레이값보다 '작을때'에서 '클때'로 바뀐 것 뿐인데 이것만으로 Continue를 사용하지 않아도 식이 성립되었다.

 

충돌체크를 해서 충돌했다면, 점수 증가와 동시에 카운트 함수를 호출한다. 카운트 함수는 호출받을 때마다 플레이어 클래스 안에 있는 멤버변수 count를 1씩 증가시킨다. 카운트 증가를 루프문에서 쓰지 않고 함수로 만들어 플레이어 클래스에서 사용한 이유는 사실상 변수 count를 플레이어 클래스 내부에서만 활용하기 때문에 루프문에서는 증가 용도로만 쓰기위해서다.

 

IsNeedRender는 화면 갱신 주기 설정 용도로 업데이트 함수가 돌고나서 값을 참으로 해서 갱신하고 해당 변수가 참일 경우에만 Render가 실행되는 식으로 갱신 주기를 설정하는 구조다.

 

2. Player 클래스

internal class Player
    {
        int pos_x, pos_y;
 
        int[] saveX = new int[20];
        int[] saveY = new int[20];
        int count = 0;
        
        public int GetPosX() { return pos_x; }
        public int GetPosY() { return pos_y; }
        public bool isAlive = false; //게임오버판정여부
        public bool isTail = false;
        public int dir = 0; // 0 오른쪽, 1 위쪽, 2 왼쪽, 3 아래쪽

        public void Count()
        {
            if(count < 19)
                ++count;
        }

        public void Start()
        {
            pos_x = GameLoop.BOARD_WIDTH / 2;
            pos_y = GameLoop.BOARD_HEIGHT / 2;
        }

        public void Move_Left()
        {
            dir = 2;
        }
        public void Move_Right()
        {
            dir = 0;
        }
        public void Move_Up()
        {
            dir = 1;
        }
        public void Move_Down()
        {
            dir = 3;
        }

        public void Update()
        {
            for (int i = 19; i > 0; i--) //배열마다 이전 값을 넣어준다. 0번째는 플레이어 위치를 넣는다.
            {
                if(count != 0)
                {
                    if (i != 0)
                    {
                        saveX[i] = saveX[i-1];
                        saveY[i] = saveY[i-1];
                    }
                        saveX[0] = pos_x;
                        saveY[0] = pos_y;
                }
            }

            if (isTail == false) //꼬리가 아닌 부분에만 방향키 이동 실행
            {
                switch(dir)
                {
                    case 0: //오른쪽
                        if (++pos_x >= GameLoop.BOARD_WIDTH)
                        {
                            pos_x = GameLoop.BOARD_WIDTH - 1;
                            isAlive = false;
                        }
                        break;
                    case 1: //위
                        if(--pos_y < 0)
                        {
                            pos_y = 0;
                        }
                        break;
                    case 2: //왼쪽
                        if(--pos_x < 0)
                        {
                            pos_x = 0;
                        }
                        break;
                    case 3: //아래
                        if(++pos_y >= GameLoop.BOARD_HEIGHT)
                        {
                            pos_y = GameLoop.BOARD_HEIGHT - 1;
                        }
                        break;
                }
            }
        }

        public void Render()
        {
            Console.SetCursorPosition(pos_x, pos_y);
            Console.ForegroundColor = ConsoleColor.Blue;
            Console.Write("@");
            
            for(int i = 0; i <= count; i++)
            {
                if(count != 0)
                {
                    Console.SetCursorPosition(saveX[i], saveY[i]);
                    Console.ForegroundColor = ConsoleColor.Cyan;
                    Console.Write("#");
                }
            }
        }
    }

 

  교수님께서 기반을 다 깔아놓아주시고 나서도 꼬리 만들기에 전혀 갈피를 못잡으니 수업시간이 거의 끝나갈 때 쯤, 교수님께서 아주 적나라한 힌트를 던져주셨다. X값과 Y값을 각각 저장하는 배열 2개를 만들고, 그 배열들로 앞 배열의 위치값을 옮겨서 저장하면 된다라고 말씀해주셨는데, 그 노골적인 힌트를 듣고 나서도 코드로 어떻게 만들어야할지 감을 잡는데 시간이 꽤 걸렸다. 어쨌든 원리는 알았으니 그 원리를 코드로 만들기만 하면 되는데 처음에 힌트를 듣기 전에는 플레이어의 방향을 나타내는 정수형 변수인 dir를 이용해 꼬리를 만들어보려고 했지만 처참히 실패했다. 그 시도 당시 교수님께서 잘되가냐고 물어보셨는데 자신있게 방법을 찾은거같다고 했지만 실상은 삽질하고 있었던 것이다. 

 

dir에 따라 플레이어 값의 뒤쪽, 플레이어 방향이 왼쪽이면 플레이어 기준 한칸 우측에다 생성하는 식으로 해서 만들어보려고 했지만 그걸 꼬리물기식으로 구현하려고하니 답이 안나왔다. 그래서 아예 슈팅게임 시간에 배웠던 총알을 클래스 배열로 만든것처럼 플레이어를 따라다니는 꼬리 클래스를 따로 만들어서 어떻게 해보려고 했는데, 교수님께서 꼬리 클래스를 쓰지 않는다고 말씀하시는 걸 듣고 이 방법도 아니구나 해서 꼬리 클래스를 지웠다. 그리고 루프 클래스 안에서 구현할 방법이 떠오르지 않아 플레이어 클래스 안에서 꼬리를 만들기로 했다. 

 

결과적으로는 쓰이지 않지만 방법을 시도하던 중 같은 클래스를 사용하는 플레이어와 꼬리를 구분하기 위애 isTail이라는 참거짓 변수를 만들었다. 꼬리가 플레이어와 같은 클래스를 사용한다면 구현에 성공하더라도 꼬리도 플레이어처럼 키입력을 받는 문제가 발생할 수 있다는 코잘알 동기의 조언을 받아 꼬리인지 플레이어인지 판정여부에 따라 키입력 판정 여부를 결정하게 조건문을 만들었었다. 

 

그래서 꼬리는 어떻게 만들었나?

업데이트 함수 첫번째 for문으로 구현했다. 꼬리길이 20을 기준으로 for문을 19까지 돌려(인덱스는 개수-1)  배열 뒤에서부터 앞 배열의 값을 넣는다. 그래서 증감식도 감소로 사용했다. 그리고 배열 0은 플레이어의 위치를 받는다. 핵심은 위치를 받는 값이 키입력으로 인해 플레이어의 위치가 변경되기 전에 해당 for문으로 먼저 각 배열에 위치값을 넣는 것이다. 뒤쪽 배열이 앞 배열의 이동하기 이전의 위치를 저장해야하기 때문이다. 직접 구현하고 보니 정말 교수님이 말씀하신 그대로만 하면 되는 거였지만 생각을 코드로 바로 구현하는 것은 아직은 미숙한 것 같다.

 

배열에 위치값을 넣고 나면 화면에 나타내기 위해 Render함수에  꼬리를 생성한다. 단 생성조건은 아이템과 충돌판정으로 호출받았던 Count() 함수로 인해 증가된 멤버변수 count가 1이상일 때로 한다. 그렇게 안하면 화면 0, 0 지점에 보이는 문제가 생긴다.

 

3. Item 클래스

internal class Item
    {
        int pos_x, pos_y;
        public int GetPosX() { return pos_x; }
        public int GetPosY() { return pos_y; }

        Random rd = new Random();

        //위치를 랜덤하게 만들어주는 함수
        //pos_x,pos_y를 임의의 위치에 할당
        public void RefreshPos()
        {
            
            pos_x = rd.Next(0, GameLoop.BOARD_WIDTH);
            pos_y = rd.Next(0, GameLoop.BOARD_HEIGHT);
        }
        //출력
        public  void Render()
        {
            Console.SetCursorPosition(pos_x, pos_y);
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.Write("$");
        }
    }

아이템은 처음 게임이 시작되는 Loop.Start()함수에서 RefreshPos함수를 호출하여 랜덤 위치값을 받아 생성하고, 이후 아이템을 먹을 때 마다 랜덤한 위치에 재생성하기 위해 해당 함수를 호출한다.

 

완성은 했지만 아직 미흡한 부분도 있다. 특히 구현하면서도 의아했던 부분은 콘솔창의 크기와 이동에 대한 의문이다.

정사각형을 구현하기 위해 설정된 콘솔버퍼 = 콘솔창의 크기는 가로60, 세로 30이다. 이는 교수님께서 콘솔창의 크기가 세로가 가로보다 2배 크기 때문이라고 하셨다. 그래서 현재 꼬리가 이동할 때도 세로로 이동할 때는 정상적으로 출력되는데 가로로 이동할 때는 무언가 압축되어(?) 표현되는 느낌이다. 그런데 플레이어의 이동을 설정하는 dir 변수를 활용한 switch 문 안에는 조건문만 있을 뿐 해당 키값이 눌렸을 때 좌표변화를 위한 변수 증감식이 전혀 없다. 상하좌우로 움직이는 코드가 어디있는 건지 모르겠다. 그리고 해당 스위치 케이스 문에 x축 이동시 증감식을 2로 하여 2칸씩 이동하게 했더니 원하는대로 구현되지도 않고 2칸씩 이동되서 아이템이 잘 먹어지지도 않는다. 좀 더 알아봐야겠다.