Bashでマインスイーパを作って、シェルスクリプトのスキルを楽しく磨こう
プログラミングを教える専門家ではない私ですが、何かを上達したいときは「楽しんで学ぶ方法」を見つけるようにしています。たとえば、シェルスクリプトを上達させたいと思ったとき、Bashでマインスイーパを実装してみることにしました。
経験豊富なBashプログラマーの方なら、このチュートリアルに沿ってターミナル向けのマインスイーパを自作しながら、遊び感覚でスキルを磨けるはずです。完成版のソースコードはGitHubリポジトリで公開しています。
準備:必要な要素を整理する
コードを書き始める前に、ゲームに必要な要素を次のように整理しました。
- 地雷原(マインフィールド)を表示する
- プレイのロジックを作る
- 開拓可能なマス(フィールド)を判定するロジックを作る
- 残りのマスと発見済みの地雷の数を管理する
- ゲーム終了時のロジックを作る
地雷原を表示する
マインスイーパの世界は、隠されたセル(マス)からなる2次元配列(列と行)です。各セルには地雷があるかもしれないし、ないかもしれません。プレイヤーの目的は、地雷のないセルを明らかにし、決して地雷を暴かないことです。今回のBash版では10×10の行列を使い、シンプルなBash配列で実装します。
まず、いくつかの変数をランダムに割り当てます。これらは地雷を配置できる場所になります。候補となる場所を絞ることで、後から拡張しやすくなります。ロジック自体はもっと洗練できますが、あえてシンプルで少し未成熟な見た目を保ちました(これは楽しみで書いたものなので、より良くするためのコントリビューションは大歓迎です)。
以下の変数はデフォルト値として宣言したもので、フィールドへの配置をランダムに呼び出すために使います。a〜gの変数は、抽出可能な地雷の計算にも利用します。
# 変数
score=0 # ゲームのスコアを格納するのに使う
# 以下の変数は、地雷から抽出可能なセル/フィールドをランダムに取得するために使用
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"
#
# 宣言
declare -a room # 配列roomを宣言。地雷の各セル/フィールドを表現する
次に、列(0〜9)と行(a〜j)からなる10×10の行列を盤面として表示します(M[10][10]は、インデックス0〜99を持つ100要素の配列です)。Bash配列について詳しく知りたい方は、「You don't know Bash: An introduction to Bash arrays」を読んでみてください。
ここでは関数 plough を定義しましょう。まずヘッダーとして、空行2行、列見出し、盤面上端を示す罫線を出力します。
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までインクリメントしながら、行番号($row。seq によって定義されます)を表す数字(%d)を出力します。
r=0 # カウンター
for row in $(seq 0 9); do
printf '%d ' "$row" # 行番号0〜9を出力
先へ進む前に、ここまでの成果を確認してみましょう。まず水平方向に [a-j] の文字列を出力し、次に [0-9] の範囲で行番号を出力しました。この2つの範囲は、ユーザーが入力する座標として、掘り起こすべきマスを特定するために使います。
各行には列との交点がありますので、新しい for ループを開きます。こちらは各列を扱い、実質的にプレイフィールド上の各セルを生成します。ソースコードで完全な定義を確認できるヘルパー関数もいくつか追加しています。各セルには地雷らしき見た目が必要なので、空のセルはドット(.)で初期化します。これには is_null_field というカスタム関数を使います。また、各セルの値を保持する配列変数として、事前に宣言したグローバル配列 room をインデックス変数 r とともに使います。r が増加するにつれてセルを順に処理し、途中で地雷を配置していきます。
for col in $(seq 0 9); do
((r+=1)) # 列の順序に従って進むにつれカウンターを増やす
is_null_field $r # フィールドが空かどうかチェックする関数。空ならドット(.)で初期化
printf '%s \e[33m%s\e[0m ' "|" "${room[$r]}" # 区切り文字を出力。${room[$r]}の最初の値は初期化直後なので'.'になる
# 列ループを閉じる
done
最後に、各行の下端を罫線で囲んで盤面をきれいに保ち、行ループを閉じます。
printf '%s\n' "|" # 行末の区切り文字を出力
printf ' %s\n' "-----------------------------------------"
# 行のforループを閉じる
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 # 配列roomにはすでにインデックス'r'を使っているので、ここでは'e'と呼ぶ
if [[ -z "${room[$e]}" ]];then
room[$r]="." # ここでドット(.)を置き、セル/地雷原を初期化する
fi
}
これで地雷原の全セルが初期化できました。次に、以下のようなシンプルな関数を宣言して呼び出すことで、利用可能な地雷の総数を取得します。
get_free_fields()
{
free_fields=0 # 変数を初期化
for n in $(seq 1 ${#room[@]}); do
if [[ "${room[$n]}" = "." ]]; then # セルが初期値のドット(.)なら、空きフィールドとしてカウント
((free_fields+=1))
fi
done
}
表示される地雷原は次のようになります。[a-j] が列、[0-9] が行です。

プレイヤーの操作を処理するロジックを作る
プレイヤーのロジックは、stdinから座標としてオプションを読み込み、地雷原上の該当フィールドを掘り起こします。Bashのパラメータ展開を使って列と行の入力を取り出し、その列をswitch文に渡して、盤面上の対応する整数表記へ変換します。仕組みを理解するには、下記case文で変数 'o' に代入される値を見てください。たとえばプレイヤーが c3 と入力すると、Bashはそれを c と 3 の2文字に分解します。話を簡単にするため、無効な入力の処理方法については省略します。
colm=${opt:0:1} # 1文字目=アルファベットを取得
ro=${opt:1:1} # 2文字目=数字を取得
case $colm in
a ) o=1;; # アルファベットを対応する整数表記に変換
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 は情報をランダムに並べ替えるLinuxユーティリティで、-i オプションはシャッフル対象のインデックスや範囲を、-n オプションは返す出力の最大数を指定します。二重括弧 (( )) によりBashで数学的な評価が可能になり、ここでは頻繁に活用します。
前述の例でstdin経由で c3 を受け取ったとしましょう。すると ro=3 となり、上記のcase文で c が整数に変換されて o=3 となります。これらを式に代入して、最終的なインデックス 'i' を計算します。
i=$(((ro*10)+o)) // BODMAS規則(演算の優先順位)に従って最終インデックスを計算
is_free_field $i $(shuf -i 0-5 -n 1) // 最終インデックスが指すセルが空いているかチェックするカスタム関数を呼び出す
最終インデックス '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
}

入力された座標が利用可能であれば、下図のように地雷が発見されます。たとえば h6 を入力すると、地雷原上のいくつかの場所にランダムな値が配置され、地雷を掘り起こした後にそれらの値がユーザーのスコアに加算されます。

ここで、冒頭で宣言した変数 [a-g] を思い出してください。今度はここで登場です。Bashの間接参照を使い、ランダムな地雷を抽出して変数 m に値を代入します。入力座標に応じて、プログラムは追加の数字セット(m)をランダムに選び、元の入力座標(上記で計算した i)に加算することで、開拓対象の追加フィールドを計算します。
なお、下記コードスニペットの文字 X は唯一のゲームオーバートリガーです。シャッフルリストに加えておくことで、shuf コマンドの妙味により、何回目かのチャンスで突然現れることもあれば、幸運な勝者には最後まで現れないこともあります。
m=$(shuf -e a b c d e f g X -n 1) // シャッフルに余分な文字Xを追加。m=Xになるとゲームオーバー
if [[ "$m" != "X" ]]; then // Xは爆発する地雷(ゲームオーバー)のトリガー
for limit in ${!m}; do // !mはmの値を表す
field=$(shuf -i 0-5 -n 1) // 再度乱数を取得し
index=$((i+limit)) // mの値をインデックスに加算して新しいインデックスを計算。mの最終要素に達するまで繰り返す
is_free_field $index $field
done
開拓されたセルは、プレイヤーが選択したセルに隣接するようにしています。

残りのマスと発見済み地雷の数を管理する
プログラムは地雷原の空きセルを追跡する必要があります。さもなければ、すべてのセルが開拓された後もプレイヤーに入力を求め続けてしまうからです。これを実現するため、変数 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 // すべての地雷を掘り当てた状態
printf '\n\n\t%s: %s %d\n\n' "You Win" "you scored" "$score"
exit 0
fi
ゲームオーバーのロジックを作る
ゲームオーバーの場合は、ちょっとした工夫でターミナルの中央に結果を表示します。その仕組みの詳細は、読者の皆さんの探究に委ねます。
if [[ "$m" = "X" ]]; then
g=0 // パラメータ展開で使用
room[$i]=X // インデックスを上書きしてXを表示
for j in {42..49}; do // 地雷原の中央に
out="gameover"
k=${out:$g:1} // 各セルにアルファベットを1文字ずつ表示
room[$j]=${k^^}
((g+=1))
done
fi
最後に、誰もが待ち望む2行の出力を行います。
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

以上です! さらに詳しく知りたい方は、私のGitHubリポジトリからこのマインスイーパや、その他のBash製ゲームのソースコードにアクセスしてみてください。Bashをもっと学び、その過程を楽しむきっかけになれば嬉しいです。
-
CSSの高度なセレクター徹底解説|隣接兄弟・属性・nth-of-typeセレクターの使い方
CSSには、要素をより細かく指定できる「高度なセレクター」が多数用意されています。代表的なものとして、隣接兄弟セレクター、一般兄弟セレクター、直接の子セレクター(子結合子)、属性セレクター、nth-of-typeセレクターなどがあります。 これらのセレクターを使いこなせるようになると、HTMLに余計なクラスやIDを追加することなく、柔軟かつ効率的にスタイルを適用できるようになります。ここでは、それぞれのセレクターの特徴と使い方を解説します。 CSSの高度なセレクターの種類 1. 隣接兄弟セレクター(+) 「A + B」の形式で記述し、要素Aの直後に隣接している要素Bのみにスタイルを適用しま
-
Bashでマインスイーパを作って、シェルスクリプトのスキルを楽しく磨こう
プログラミングを教える専門家ではない私ですが、何かを上達したいときは「楽しんで学ぶ方法」を見つけるようにしています。たとえば、シェルスクリプトを上達させたいと思ったとき、Bashでマインスイーパを実装してみることにしました。 経験豊富なBashプログラマーの方なら、このチュートリアルに沿ってターミナル向けのマインスイーパを自作しながら、遊び感覚でスキルを磨けるはずです。完成版のソースコードはGitHubリポジトリで公開しています。 準備:必要な要素を整理する コードを書き始める前に、ゲームに必要な要素を次のように整理しました。 地雷原(マインフィールド)を表示する プレイのロジックを作る 開