Computer >> 컴퓨터 >  >> 프로그래밍 >> Bash 프로그래밍

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

프로그래밍 가르치기의 전문가는 아니지만, 저는 무언가를 더 잘하고 싶을 때마다 재미있게 배울 수 있는 방법을 찾곤 합니다. 예를 들어 셸 스크립팅 실력을 높이고 싶었을 때, 저는 Bash로 지뢰찾기(Minesweeper) 게임을 직접 만들어보며 연습하기로 했습니다.

경험 많은 Bash 프로그래머라면, 즐거움과 함께 실력을 갈고닦을 수 있는 이 프로젝트에 도전해 보세요. 터미널에서 동작하는 나만의 지뢰찾기를 따라 만들어 볼 수 있습니다. 완성된 소스 코드는 GitHub 저장소에서 확인할 수 있습니다.

준비하기

코드를 작성하기 전에, 게임을 만들기 위해 필요한 요소들을 먼저 정리했습니다:

  1. 지뢰밭(minefield) 출력하기
  2. 게임 플레이 로직 만들기
  3. 이용 가능한 지뢰밭을 판단하는 로직 만들기
  4. 남아 있는 칸과 이미 발굴한 지뢰 개수 세기
  5. 게임 종료 로직 만들기

지뢰찾기에서 게임의 세계는 숨겨진 칸(column과 row)으로 이루어진 2차원 배열입니다. 각 칸에는 폭발 지뢰가 있을 수도 있고 없을 수도 있습니다. 플레이어의 목표는 지뢰가 없는 칸을 열어내되, 절대 지뢰가 있는 칸은 열지 않는 것입니다. Bash 버전에서는 단순한 Bash 배열로 구현된 10x10 매트릭스를 사용합니다.

먼저 몇 가지 랜덤 변수를 할당합니다. 이것들은 지뢰판 위에 지뢰가 배치될 수 있는 위치들입니다. 위치의 수를 제한해 두면 나중에 확장하기가 쉽습니다. 로직 자체는 더 개선할 여지가 있지만, 저는 게임이 단순하고 약간 서툰 모습을 유지하기를 원했습니다. (재미로 만든 것이니, 더 멋져 보이도록 개선해 주시면 언제든 환영합니다.)

아래 변수들은 필드 배치를 무작위로 호출하기 위해 선언한 기본 변수들입니다. a~g 변수들은 발굴 가능한 지뢰를 계산할 때 사용하게 됩니다:

# variables
score=0 # will be used to store the score of the game
# variables below will be used to randomly get the extract-able cells/fields from our mine.
a="1 10 -10 -1"
b="-1 0 1"
c="0 1"
d="-1 0 1 -2 -3"
e="1 2 20 21 10 0 -10 -20 -23 -2 -1"
f="1 2 3 35 30 20 22 10 0 -10 -20 -25 -30 -35 -3 -2 -1"
g="1 4 6 9 10 15 20 25 30 -30 -24 -11 -10 -9 -8 -7"
#
# declarations
declare -a room # declare an array room, it will represent each cell/field of our mine.

다음으로 열(0~9)과 행(a~j)으로 구성된 10x10 매트릭스 형태의 게임판을 출력합니다. 이것이 게임의 지뢰밭 역할을 합니다. (M[10][10]은 인덱스 0~99를 가진 100개 값의 배열입니다.) Bash 배열에 대해 더 알고 싶다면 You don't know Bash: An introduction to Bash arrays 문서를 참고하세요.

이 함수를 plough라고 부르겠습니다. 먼저 헤더부터 출력합니다. 빈 줄 두 개, 열 제목, 그리고 게임판 상단을 그리는 구분선입니다:

printf '\n\n'
printf '%s' " a b c d e f g h i j"
printf '\n %s\n' "-----------------------------------------"

그다음 몇 개의 행이 채워졌는지 추적할 카운터 변수 r를 설정합니다. 참고로 이 카운터 변수 'r'은 이후 게임 코드에서 배열의 인덱스로도 사용됩니다. Bash for 루프 안에서 seq 명령으로 0부터 9까지 증가시키며, 행 번호(seq로 정의된 $row)를 나타내는 숫자(%d)를 출력합니다:

r=0 # our counter
for row in $(seq 0 9); do
printf '%d ' "$row" # print the row numbers from 0-9

여기서 잠깐, 지금까지 만든 것을 확인해 봅시다. 먼저 시퀀스 [a-j]를 가로로 출력했고, 그다음 [0-9] 범위의 행 번호를 출력했습니다. 이 두 범위는 사용자 입력 좌표로 활용되어 발굴할 지뢰의 위치를 지정하는 데 사용됩니다.

다음으로, 각 행에는 열이 교차하는 지점이 있으므로 새로운 for 루프를 엽니다. 이 루프는 각 열을 담당하며, 사실상 게임판의 모든 칸을 생성하는 역할을 합니다. 전체 정의는 소스 코드에서 확인할 수 있는 헬퍼 함수들을 추가했습니다. 각 칸이 지뢰밭처럼 보이려면 무언가가 필요하므로, is_null_field라는 커스텀 함수를 사용해 빈 칸을 점(.)으로 초기화합니다. 또한 각 칸의 값을 저장할 배열 변수가 필요한데, 미리 선언한 전역 배열 변수 room과 인덱스 변수 r를 함께 사용합니다. r이 증가함에 따라 칸들을 순회하면서 진행합니다.

  for col in $(seq 0 9); do
((r+=1)) # increment the counter as we move forward in column sequence
is_null_field $r # assume a function which will check, if the field is empty, if so, initialize it with a dot(.)
printf '%s \e[33m%s\e[0m ' "|" "${room[$r]}" # finally print the separator, note that, the first value of ${room[$r]} will be '.', as it is just initialized.
#close col loop
done

마지막으로 각 행의 하단을 선으로 감싸 판을 깔끔하게 유지한 뒤, 행 루프를 닫습니다:

printf '%s\n' "|"   # print the line end separator
printf ' %s\n' "-----------------------------------------"
# close row for loop
done
printf '\n\n'

완성된 plough 함수의 전체 모습은 다음과 같습니다:

plough()
{
r=0
printf '\n\n'
printf '%s' " a b c d e f g h i j"
printf '\n %s\n' "-----------------------------------------"
for row in $(seq 0 9); do
printf '%d ' "$row"
for col in $(seq 0 9); do
((r+=1))
is_null_field $r
printf '%s \e[33m%s\e[0m ' "|" "${room[$r]}"
done
printf '%s\n' "|"
printf ' %s\n' "-----------------------------------------"
done
printf '\n\n'
}

is_null_field 함수가 필요하다는 결론을 내리는 데 시간이 좀 걸렸으므로, 이 함수가 하는 일을 자세히 살펴보겠습니다. 우리는 게임 시작 시점부터 신뢰할 수 있는 상태값이 필요합니다. 그 선택은 임의적입니다. 숫자나 어떤 문자든 가능했겠죠. 저는 모든 것을 점(.)으로 초기화하기로 했습니다. 그래야 게임판이 예뻐 보인다고 생각했기 때문입니다. 실제 모습은 다음과 같습니다:

is_null_field()
{
local e=$1 # we used index 'r' for array room already, let's call it 'e'
if [[ -z "${room[$e]}" ]];then
room[$r]="." # this is where we put the dot(.) to initialize the cell/minefield
fi
}

이제 지뢰밭의 모든 칸이 초기화되었으므로, 아래와 같은 간단한 함수를 선언하고 호출하여 이용 가능한 지뢰(칸)의 총 개수를 구합니다:

get_free_fields()
{
free_fields=0 # initialize the variable
for n in $(seq 1 ${#room[@]}); do
if [[ "${room[$n]}" = "." ]]; then # check if the cells has initial value dot(.), then count it as a free field.
((free_fields+=1))
fi
done
}

출력된 지뢰밭은 다음과 같으며, [a-j]는 열, [0-9]는 행을 나타냅니다.

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

플레이어를 구동하는 로직 만들기

플레이어 로직은 stdin에서 좌표 형태의 옵션을 읽어 지뢰밭의 해당 필드를 발굴합니다. Bash의 파라미터 확장(parameter expansion)을 사용해 열과 행 입력을 분리한 후, 열 값을 스위치(switch)에 넣어 게임판상의 정수 표기로 변환합니다. 이를 이해하려면 아래 switch-case 문에서 변수 'o'에 할당되는 값을 살펴보세요. 예를 들어 플레이어가 c3를 입력하면 Bash는 이를 두 문자, 즉 c3으로 분리합니다. 설명의 편의를 위해 잘못된 입력 처리 방법은 생략하겠습니다.

  colm=${opt:0:1}  # get the first char, the alphabet
ro=${opt:1:1} # get the second char, the digit
case $colm in
a ) o=1;; # finally, convert the alphabet to its equivalent integer notation.
b ) o=2;;
c ) o=3;;
d ) o=4;;
e ) o=5;;
f ) o=6;;
g ) o=7;;
h ) o=8;;
i ) o=9;;
j ) o=10;;
esac

그런 다음 정확한 인덱스를 계산하고, 입력 좌표의 인덱스를 해당 필드에 할당합니다.

여기서는 shuf 명령도 많이 사용됩니다. shuf는 정보의 무작위 순열(random permutation)을 제공하도록 설계된 Linux 유틸리티로, -i 옵션은 섞을 인덱스나 가능한 범위를, -n 옵션은 반환받을 최대 출력 개수를 의미합니다. 이중 괄호(double parentheses)는 Bash에서 수학적 평가를 가능하게 하며, 여기서는 이를 아주 많이 사용하게 됩니다.

앞선 예시에서 stdin으로 c3를 받았다고 가정해 봅시다. 그러면 ro=3이 되고, 위 switch-case 문에서 c가 정수 3으로 변환되어 o=3이 됩니다. 이 값들을 최종 인덱스 'i'를 계산하는 공식에 넣습니다.

  i=$(((ro*10)+o))   # Follow BODMAS rule, to calculate final index. 
is_free_field $i $(shuf -i 0-5 -n 1) # call a custom function that checks if the final index value points to a an empty/free cell/field.

최종 인덱스 'i'가 어떻게 계산되는지 수식을 따라가 보겠습니다:

i=$(((ro*10)+o))
i=$(((3*10)+3))=$((30+3))=33

최종 인덱스 값은 33입니다. 위에서 출력한 게임판에서 이 인덱스는 33번째 칸을 가리키며, 이는 3번째(0부터 세므로 사실상 4번째) 행과 3번째(C) 열에 해당합니다.

이용 가능한 지뢰밭을 판단하는 로직 만들기

지뢰를 발굴하려면, 좌표가 해석되고 인덱스가 찾아진 후 프로그램이 해당 필드가 이용 가능한지 확인해야 합니다. 이용 불가능하다면 프로그램은 경고 메시지를 표시하고, 플레이어는 다른 좌표를 선택하게 됩니다.

이 코드에서 어떤 칸은 점(.) 문자를 포함하고 있을 때 이용 가능한 것으로 간주됩니다. 이용 가능하다고 판단되면 해당 칸의 값이 초기화되고 점수가 갱신됩니다. 반대로 점이 없어 이용 불가능한 경우라면 not_allowed 변수가 설정됩니다. 분량상, 게임 로직 내 경고 문구의 내용은 소스 코드에서 직접 확인해 보시기 바랍니다.

is_free_field()
{
local f=$1
local val=$2
not_allowed=0
if [[ "${room[$f]}" = "." ]]; then
room[$f]=$val
score=$((score+val))
else
not_allowed=1
fi
}

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

입력한 좌표가 이용 가능하면 아래와 같이 지뢰가 발굴됩니다. h6를 입력으로 제공하면 지뢰밭 곳곳에 무작위 값들이 채워지고, 이 값들은 지뢰가 발굴된 후 사용자의 점수에 더해집니다.

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

이제 시작 부분에서 선언했던 변수들 [a-g]를 기억하시나요? 바로 여기서 사용합니다. Bash의 간접 참조(indirection)를 활용해 이 값들을 변수 m에 할당하여 무작위 지뢰를 발굴합니다. 즉, 입력 좌표에 따라 프로그램은 무작위 추가 숫자 집합(m)을 선택하고, 앞서 계산한 인덱스 i(위에서 계산한 값)에 더하여 추가로 채워질 필드들을 계산합니다(위 스크린샷 참조).

주목할 점은 아래 코드 조각의 X 문자입니다. 이것이 유일한 GAME-OVER 트리거입니다. 셔플 목록에 추가하여 무작위로 나타나도록 했는데, shuf 명령의 매력 덕분에 X는 몇 번의 기회 끝에 나타날 수도 있고, 운 좋은 승자에게는 아예 나타나지 않을 수도 있습니다.

m=$(shuf -e a b c d e f g X -n 1)   # add an extra char X to the shuffle, when m=X, its GAMEOVER
if [[ "$m" != "X" ]]; then # X will be our explosive mine(GAME-OVER) trigger
for limit in ${!m}; do # !m represents the value of value of m
field=$(shuf -i 0-5 -n 1) # again get a random number and
index=$((i+limit)) # add values of m to our index and calculate a new index till m reaches its last element.
is_free_field $index $field
done

저는 공개된 모든 칸이 플레이어가 선택한 칸과 연속(contiguous)되기를 원했습니다.

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

이용 가능한 칸과 발굴된 지뢰 개수 세기

프로그램은 지뢰밭에 남아 있는 칸의 수를 추적해야 합니다. 그렇지 않으면 모든 칸이 공개된 후에도 계속해서 플레이어에게 입력을 요구하게 됩니다. 이를 구현하기 위해 free_fields라는 변수를 만들고 처음에 0으로 설정합니다. 지뢰밭에 남아 있는 이용 가능한 칸의 수만큼 for 루프가 정의되고, 어떤 칸이 점(.)을 포함하고 있다면 free_fields의 카운트가 1씩 증가합니다.

get_free_fields()
{
free_fields=0
for n in $(seq 1 ${#room[@]}); do
if [[ "${room[$n]}" = "." ]]; then
((free_fields+=1))
fi
done
}

잠깐, 만약 free_fields=0이라면 어떻게 될까요? 그것은 사용자가 모든 지뢰를 발굴했다는 의미입니다. 정확한 코드를 살펴보면 더 잘 이해할 수 있습니다.

if [[ $free_fields -eq 0 ]]; then   # well that means you extracted all the mines.
printf '\n\n\t%s: %s %d\n\n' "You Win" "you scored" "$score"
exit 0
fi

게임 오버(Gameover) 로직 만들기

게임 오버 상황에서는 약간의 영리한 로직을 사용해 터미널 중앙에 메시지를 출력합니다. 그 작동 원리는 독자 여러분이 직접 탐구해 보시기 바랍니다.

if [[ "$m" = "X" ]]; then
g=0 # to use it in parameter expansion
room[$i]=X # override the index and print X
for j in {42..49}; do # in the middle of the minefields,
out="gameover"
k=${out:$g:1} # print one alphabet in each cell
room[$j]=${k^^}
((g+=1))
done
fi

마지막으로, 모두가 기다려온 두 줄을 출력할 수 있습니다.

if [[ "$m" = "X" ]]; then
printf '\n\n\t%s: %s %d\n' "GAMEOVER" "you scored" "$score"
printf '\n\n\t%s\n\n' "You were just $free_fields mines away."
exit 0
fi

Bash로 지뢰찾기 게임을 만들며 셸 스크립팅 실력 연마하기

이것으로 마무리입니다! 더 알고 싶으시다면 제 GitHub 저장소에서 이 지뢰찾기 게임과 다른 Bash 게임들의 소스 코드를 확인하세요. 여러분이 더 많은 Bash를 학습하고, 그 과정에서 즐거움을 느끼는 계기가 되기를 바랍니다.