본문 바로가기
C# 콘솔 & 윈도우폼

[C#] 콘솔 창으로 슈팅게임 구현하기 1 : 갤러그

by 17번 일개미 2022. 6. 30.
728x90

 

의의

콘솔 앱을 제작하여, 콘솔 창으로만 동작하는 간단한 슈팅게임을 만들어보고자 한다.

 

조건

1. 플레이어는 키보드 방향키를 입력받아 좌, 우로 움직일 수 있다.

2. 적 플레이어는 임의로 지정한 방식에 따라 좌, 우로 움직인다.

3. 스페이스 바를 입력하여 총알을 발사할 수 있다.

4. 총알과 적 플레이어가 충돌 시 총알은 사라진다.

5. 총알은 시야에서 벗어나도 사라진다.

6. 총알이 적에게 명중하면 100점을 획득하며 화면에 좌측 상단에 출력한다.

 

구현

전체 코드

 

Program.cs

using System;
using System.Runtime.InteropServices;
using System.Windows.Input;
using System.Threading.Tasks;
using System.Threading;


/*  - 프로그램 구조 
    
    1. 전체적인 콘솔 창을 15행 20열의 2차원 행렬로 본다.
    2. 각 원소는 char[] 형의 pixel 이라는 이름으로 정의하고, 각 원소는 기본적으로 ' ' 공백을 담고 있다.
    3. 플레이어, 적, 총알은 각 pixel의 위치에 char 형으로 삽입된다.
    4. Keyboard Input 이 일어날 때 마다, 프레임의 갱신이 일어난다.
    5. 단, 사격을 하게되면 사격이 종료될 시점까지 렌더링 중지

     - 한계점

    1. 맨날 유니티에서 정의된 Update 문을 사용하다 보니, 직접적으로 Update와 같은 기능을 하는 함수를
        구현하기 어려웠다.
    2. 따라서 프레임단위로 While문을 돌면서 연산을 하려고 했으나, ReadKey로 Keyboard 입력을 받다보니
        Input이 들어오기 전에는 다음 While문이 돌지 않았다.  >> 무언가 독립적인 프로세서가 키 감지를 담당하고 메인 함수는 그 결과에 따른 렌더링을 해야할 것 같다는 생각이 든다.
        >> 하지만 분리하는 방법을 모르겠음
    3. 설계 자체를 2차원 배열로 구성하여 배열 하나를 픽셀 하나로 보고 구현하였는데, 구조의 효율성이 떨어져보임
        >> 변경된 픽셀만 계산하지 않고, 전부 지우고 다시 그려야함. >> 근데 Console.Clear() 로 지우지 않으면 출력물이 쌓여 지저분해짐
    4. 걍 완성도가 떨어짐
 
 */

namespace ShootingGame
{
    class Program
    {
        static int row = 15; static int column = 20; // 표시할 행과 열의 수
        static int score = 0; // 점수 저장 변수
        static char[,] pixel = new char[row, column]; // 표시할 픽셀 2차원 배열
        public static int[] playerPos = { 14, 10 }; // 플레이어 현 위치
        public static int[] bulletPos = { playerPos[0] - 1, playerPos[1] }; // 총알의 현 위치
        public static int[] enemyPos = { 6, 10 }; // 적의 현 위치
        static void Main(string[] args)
        {
            int width = 20; int height = 17; // 윈도우 창 사이즈 설정

            // const int waitTick = 1000 / 30; // 30프레임
            //int lastTick = 0;

            InitializeWindow(width, height); // 윈도우 창 초기화
            DrawFrame(); // 첫 프레임 그리기
            while (true)// 반복
            {
                DrawFrame(); // 계산에 따른 프레임 갱신
                Key.GetKey(); // 다음 키입력에 대비

                Console.SetCursorPosition(0, 0); // 커서 맨처음으로 >> 근데 Console.Clear() 사용해서 큰 의미 없으나
                                                    // 같이 사용하면 그나마 덜 끊기는 것처럼 보임.
            }


        }
        // 윈도우 세팅
        static void InitializeWindow(int width, int height)
        {
            Console.Title = "Console Shooting"; // 제목 설정
            Console.SetWindowSize(width, height); // 창 크기 설정
            Console.BackgroundColor = ConsoleColor.White; // 배경 색 설정(흰색)
            Console.Clear(); // 클리어
        }
        // 화면 그리기

        public static void DrawFrame() // 프레임 갱신
        {
            Console.Clear(); // 일단 전 프레임을 전부 지운다.

            bulletPos[0] = playerPos[0] - 1; // 총알을 쏘게되면 플레이의 위치보다 1만큼 앞라인에서 출발한다.
            bulletPos[1] = playerPos[1];    // 열의 위치는 일정함.

            Random rand = new Random();
            int number = rand.Next(-1, 2); // -1, 0, 1 중 난수를 생성하고 적 위치에 합산한다.
            enemyPos[1] += number;  // 프레임 호출될 때마다 적은 랜덤하게 좌우로 한칸씩 이동

            // 만약 적 또는 플레이어가 화면 사이즈 밖으로 벗어나게 된다면, 가장 자리에 위치를 Fix 시킴.
            if (enemyPos[1] <= 0) enemyPos[1] = 0; if (enemyPos[1] >= column - 1) enemyPos[1] = column - 1;
            if (playerPos[1] <= 0) playerPos[1] = 0; if (playerPos[1] >= column - 1) playerPos[1] = column - 1;

            // 2차원 배열 순회
            for (int i = 0; i < row; i++)
            {
                for(int j = 0; j < column; j++)
                {
                    // 적의 포지션이라면
                    if(i == enemyPos[0] && j == enemyPos[1])
                    {
                        pixel[i, j] = '▼'; // 적 캐릭터 저장
                    }
                    else if(i == playerPos[0] && j == playerPos[1]) // 플레이어 라면
                    {
                        pixel[i, j] = '▲'; // 플레이어 저장
                    }
                    else // 아무것도 아니면
                    {
                        pixel[i, j] = ' '; // 공백
                    }
                }
            }
            // DarkBlue컬러로 점수판 생성
            Console.ForegroundColor = ConsoleColor.DarkBlue;
            Console.WriteLine("Score " + score);
            Console.ResetColor(); // 색상 초기화
            Console.BackgroundColor = ConsoleColor.White; // 배경색 흰색
            // 다시 배열 순회
            for (int i = 0; i < row; i++)
            {
                for (int j = 0; j < column; j++)
                {
                    if(i == playerPos[0] && j == playerPos[1]) // 배열의 원소가 플레이어 라면
                    {
                        Console.ForegroundColor = ConsoleColor.DarkGreen; // 청록색으로
                        Console.Write(pixel[i, j]); // 플레이어 원소를 그린다.
                        Console.ResetColor(); // 색상 초기화
                        Console.BackgroundColor = ConsoleColor.White; // 배경 흰색 초기화
                    }
                    else if (i == enemyPos[0] && j == enemyPos[1]) // 배열의 원소가 적이라면
                    {
                        Console.ForegroundColor = ConsoleColor.Red; // 빨간색
                        Console.Write(pixel[i, j]);
                        Console.ResetColor();
                        Console.BackgroundColor = ConsoleColor.White;
                    }
                    else
                    {
                        Console.Write(pixel[i, j]); // 아무것도 아니면 공백 있던거 출력
                    }
                }
                Console.WriteLine(); // 한 행 끝날때마다 개행
            }
        }
        
        // 공격 시 총알 이동 >> 총알 발사하면 총알이 거리를 벗어나 사라지거나, 적에게 맞기 까지 다른 행동 정지됨.
        public static void Attack() // 쓰레드 Sleep 으로 100ms 단위로 지연되면서 재귀호출된다.
        {
            Console.Clear();
            // 배열 순회
            for (int i = 0; i < row; i++)
            {
                for (int j = 0; j < column; j++)
                {
                    if (i == bulletPos[0] && j == bulletPos[1]) // 총알 위치라면
                    {
                        pixel[i, j] = '*'; // 총알 그림 대입
                    }
                    else if (i == enemyPos[0] && j == enemyPos[1])
                    {
                        pixel[i, j] = '▼';
                    }
                    else if (i == playerPos[0] && j == playerPos[1])
                    {
                        pixel[i, j] = '▲';
                    }
                    else
                    {
                        pixel[i, j] = ' ';
                    }
                }
            }
            Console.ForegroundColor = ConsoleColor.DarkBlue;
            Console.WriteLine("Score " + score);
            Console.ResetColor();
            Console.BackgroundColor = ConsoleColor.White;

            for (int i = 0; i < row; i++)
            {
                for (int j = 0; j < column; j++)
                {
                    if (i == bulletPos[0] && j == bulletPos[1]) // 총알 위치라면
                    {
                        Console.ForegroundColor = ConsoleColor.Blue; // 파란색으로
                        Console.Write(pixel[i, j]);
                        Console.ResetColor();
                        Console.BackgroundColor = ConsoleColor.White;
                    }
                    else if (i == enemyPos[0] && j == enemyPos[1])
                    {
                        Console.ForegroundColor = ConsoleColor.Red;
                        Console.Write(pixel[i, j]);
                        Console.ResetColor();
                        Console.BackgroundColor = ConsoleColor.White;
                    }
                    else if (i == playerPos[0] && j == playerPos[1])
                    {
                        Console.ForegroundColor = ConsoleColor.DarkGreen;
                        Console.Write(pixel[i, j]);
                        Console.ResetColor();
                        Console.BackgroundColor = ConsoleColor.White;
                    }
                    else
                    {
                        Console.Write(pixel[i, j]);
                    }
                }
                Console.WriteLine();
            }
            // Attack()은 재귀호출된다. 
            bulletPos[0] -= 1; // 그리기가 끝나면 총알은 다음 그리기에서 앞으로 전진할 수 있게 행 index가 1 감소한다.

            if (bulletPos[0] == enemyPos[0] && bulletPos[1] == enemyPos[1])  // 만약 총이 적에게 맞는다면
            {
                pixel[bulletPos[0], bulletPos[1]] = ' '; // 총알 사라짐
                score += 100;                           // 점수는 100점 증가
                bulletPos[0] = playerPos[0] - 1;    // 앞으로 생성 될 총알 위치 초기화
                bulletPos[1] = playerPos[1];
                pixel[enemyPos[0], enemyPos[1]] = ' '; // 적 사라짐
                DrawFrame(); // 프레임 다시 그리기
                return; // 재귀함수 탈출
            }
            else if (bulletPos[0] < 1) // 만약 시야를 벗어난다면
            {
                pixel[bulletPos[0], bulletPos[1]] = ' '; // 총알 사라짐
                bulletPos[0] = playerPos[0] - 1; // 초기화
                bulletPos[1] = playerPos[1];
                DrawFrame(); // 프레임그리기
                return; // 재귀 탈출
            }
            else
            {
                //var t = Task.Run(async delegate { await Task.Delay(100); });
                //t.Wait();
                Thread.Sleep(100); // 100 ms 지연 << 쓰레드 sleep 이라 다른 기능 정지됨.
                Attack(); // 재귀
            }
        }

    }

}

 

Key.cs

 

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Input;

namespace ShootingGame
{
    class Key : Program // 키 입력을 독립적으로 받을 수 있을지 고민하다가 따로 소스가 생성됨.
    {
        public static void GetKey()
        {
            ConsoleKeyInfo input; // 키 입력 저장

            while (true)
            {
                input = Console.ReadKey(true); // 입력
                SwitchKey(input); // 입력에 따른 결과
            }
        }
        static void SwitchKey(ConsoleKeyInfo input)
        {
            if (input.Key == ConsoleKey.RightArrow) // 오른쪽 화살표
            {
                playerPos[1] += 1; // 플레이어의 x좌표가 1 증가
                DrawFrame(); // 그에 따른 새 프레임 그리기

            }
            else if (input.Key == ConsoleKey.LeftArrow) // 왼쪽 화살표
            {
                playerPos[1] -= 1; // 플레이어의 x좌표가 1 감소
                DrawFrame(); // 그에 따른 새 프레임 그리기
            }
            else if (input.Key == ConsoleKey.Spacebar) // 스페이스바 : 공격
            {
                Attack(); // 공격함수 실행
            }
            else if (input.Key == ConsoleKey.Escape) // ESC 입력시 종료
            {
                Environment.Exit(0);
            }
        }
    }
}

구조

1. 플레이 화면은 15행 20열의 2차원 배열의 구조를 가진다.

2. 각 배열의 원소를 픽셀로 정의하고, 각 픽셀은 공백, 플레이어, 적, 총알의 정보를 char 형으로 저장한다.

3. 매 화면은 프레임을 갱신하듯이, 콘솔에 지우고 새로 작성한다.

 

한계점

1. 키보드 입력이 있을 때만 화면 정보가 갱신된다.

2. 총알을 발사하는 도중에는 총알이 사라지기 전까지 플레이어 이동에 대한 화면 갱신이 불가능하다.

 

원인

1. 키보드 입력이 있을 때만 화면 정보가 갱신된다.

  >> Console.ReadKey() 가 while문 안에서 돌고 있기에, 키보드 입력이 발생하기 전까지는 DrawFrame()이

      실행되지 않는다. >> 키보드 입력이 없는 경우에 갱신을 할 수 있는 방법을 찾지 못했다.

 

2. 총알을 발사하는 도중에는 총알이 사라지기 전까지 플레이어 이동에 대한 화면 갱신이 불가능하다.

  >> 1번의 문제로 인하여, 화면갱신이 불가능하지만, 총알의 발사는 인풋과 상관없이 재귀함수 호출을 통해 자동적으로        갱신한다.

728x90