マインスイーパー

技術文章さくさく理解できる C/C++ コンソールアプリ入門
> マインスイーパー


 

 

マインスイーパー
Nobuhide Tsuda
May-2014

概要

コンソールで動作する簡易なマインスイーパー(下図は実行画面)をC++で書いたので、簡単に説明する。

本稿で解説するマインスイーパーのソースコードは以下から取得できる。
ライセンスは CDDL 1.0 なので、自由に改変して再配布してOKだぞ。

マインスイーパー ソースコード

カテゴリ:

マインスイーパの遊び方

盤面サイズは 9x9 で、その中に10個の爆弾が潜んでいる。
プレイヤーのミッションはそれらの爆弾を全て見つけることだ。

盤面のマスを a-i, 1-9 で指定して開けることができる。そこに爆弾があるとゲームオーバーだ。
爆弾が無い場合は、そのマス目の周り(8近傍)にある爆弾の数が表示される。
その数字を頼りに爆弾以外のマスを全て開けることに成功すると、ゲームクリアとなるぞ。

画面説明

マインスイーパーをやったことある人であれば、スクリーンショットを見れば意味がわかると思うが、念の為に説明しておく。

ソース解説

定数

ソースコードには数値を直接書くのはなるべく避けなくてはいけない。
ソースを読むときに、数値の意味が分からないことがあるし、後で変更したいときにどこを変えていいかわからず、保守性に問題があるからだ。
※ 変更したい時は全置換すればいいと思うかもしれないが、同じ数字が使ってあると、そううまくはいかない。

#define     N_MINE    10         // 爆弾数
#define     BD_WD     9          // 盤面横数
#define     BD_HT      9          // 盤面縦数

本プログラムで定義した定数は以上の3つだけだ。
盤面に隠れている爆弾数、盤面の縦横サイズだ。

データ構造

速度が遅いので、筆者はあまり2次元配列は好きではないのだが、わかりやすさを優先することとし、 本プログラムでは盤面のデータ構造に2次元配列を採用することにした。
必要な情報は、各マス目に爆弾があるかどうか、そのマス目の周りにいくつ爆弾があるか。そしてそのマスが開かれているかどうかだ。

それぞれを bool, char, bool 型のグローバルな2次元配列で表現している。

bool  g_open[BD_WD+2][BD_HT+2];       // マス目を開いているかどうか
bool  g_mine[BD_WD+2][BD_HT+2];       // 爆弾があるかどうか
char g_nMine[BD_WD+2][BD_HT+2];      // 0-8: 周りの爆弾の数

配列サイズは、縦横共に +2 していることに注意してほしい。
これは、境界チェックを省くために、上下左右にそれぞれ1マス余計に確保しているのだ。

ひとマス余計に確保しているので、x, y の有効範囲は [1, BD_WD], [1, BD_HT] となる。

このように実際に表示される部分より余分にデータを確保するテクニックはとてもよく使う。
ぜひマスターして使えるようになって欲しいテクニックのひとつだ。

盤面初期化

void init_board() は盤面初期化を行う関数だ。
g_open, g_mine, g_nMine 配列の初期化を行う。

まずは、全範囲について、全てのマス目を閉じた状態、爆弾は無し、周りの爆弾数は0に設定する。
ソースコードは下記のようになる。

    for (int x = 0; x < BD_WD+2; ++x) {
        for (int y = 0; y < BD_HT+2; ++y) {
            g_mine[x][y] = false;      // 爆弾無し
            g_nMine[x][y] = 0;         // 周りの爆弾数は0
            g_open[x][y] = false;      // マス目は閉じた状態
        }
    }

x == 0 等の盤面外の部分については必ずしも初期化する必要は無いのだが、ものはついでなので初期化している。
気に入らない人は for文ループの範囲を [1, BD_WD] に変更するとよいだろう。

グローバル配列は 0 で初期化されるので、この処理は不要ではないかと思う人もいるかもしれないが、 ゲームは何回も行うものなので、2回目の時は1回目のデータを消すために初期化が必須である。

ついで、爆弾を盤面に配置する処理を行う。

    // 爆弾を配置
    for (int i = 0; i < N_MINE; ++i) {
        int x, y;
        do {
            x = rand() % BD_WD + 1;      // [1, BD_WD]
            y = rand() % BD_HT + 1;       // [1, BD_HT]
        } while (g_mine[x][y]);       // 既に爆弾が置いてある
        g_mine[x][y] = true;          //  爆弾配置
        // 8近傍の爆弾数をインクリメント
        g_nMine[x-1][y-1] += 1;
        g_nMine[x][y-1] += 1;
        g_nMine[x+1][y-1] += 1;
        g_nMine[x-1][y] += 1;
        g_nMine[x+1][y] += 1;
        g_nMine[x-1][y+1] += 1;
        g_nMine[x][y+1] += 1;
        g_nMine[x+1][y+1] += 1;
    }

設置する爆弾の個数分だけ、for文で回す。

[1, BD_WD], [1, BD_HT] 範囲の乱数を発生させる。そこに既に爆弾があった場合は、爆弾が無いところに当たるまで do-while ループを繰り返す。

g_mine[x][y] = true; で爆弾を設置する。

次が本プログラムのキモとも言える部分で、(x, y) の8近傍について、周辺爆弾個数 g_nMine[][] をインクリメントしている。
(x, y) の左上は (x-1, y-1)、真上は (x, y-1)、・・・右下は (x+1, y+1) だ。
この時、境界チェックを省けるのが、配列をひとマス余分に確保している利点である。
ひとマス余分に確保する利点をご理解いただけたであろうか?

演習問題:

  1. 配列が余分なひとマスを含まず、g_nMine[BD_WD][BD_HT]; と宣言されている場合は、境界チェックを行う必要がある。 境界チェックを行うコードを実際に記述してみなさい。

盤面表示

void print_board() は盤面を表示する関数だ。
g_open[][], g_mine[][], g_nMine[][] を参照して盤面を描画するぞ。

const char *digitStr[] = {
    "1", "2", "3", "4", "5", "6", "7", "8", "9",
};
void print_board()
{
    cout << "\n abcdefghi\n";
    for (int y = 1; y <= BD_HT; ++y) {
        cout << digitStr[y - 1];
        for (int x = 1; x <= BD_WD; ++x) {
            if( !g_open[x][y] )      //  開いていない
                cout << "■";
            else if( g_mine[x][y] )     // 地雷有り
                cout << "★";
            else if( !g_nMine[x][y] )       // 周りに地雷無し
                cout << "・";
            else      //  周りに地雷有り
                cout << " " << (int)g_nMine[x][y];
        }
        cout << "\n";
    }
    cout << "\n";
}

まずは上部に a-i を表示。

つぎに、for文で y と x を順に回す。
行頭には1~9の番号を全角で表示する。関数の前にある文字列データはそのためのものだ。

まずは g_open[][] を参照し、マス目が開かれていなければ "■" を表示。
開かれれいる場合、そこに地雷があれば "★" を表示。
地雷が無く、周辺8近傍の地雷数がゼロであれば "・" を表示。
そうでなければ地雷数を半角で表示だ。

このコードは素直に書いただけで、格別難しいとこは無いと思う。
あえて注意する点を言えば、配列を1マス余計に確保しているので、ループ部分の条件が 1 から BD_WD になっているところくらいか。

演習問題:

  1. ■、★、・ をあなたの好きな文字に適当に変え、ビルド・実行してみなさい。
  2. 周辺8近傍の地雷数を半角ではなく全角で表示するよう修正してみなさい。
  3. 現在のプログラムでは縦横ともに9マスまでしか対応していない。横は26マスまで、縦は99マスまで対応可能にしなさい。

指定されたマス目を開く

void open(int x, int y) は、引数で指定されたマス目を開く関数だ。

// (x, y) を開く、x は [1, BD_WD], y は [1, BD_HT] の範囲
// 周りの爆弾数がゼロならば、周りも開く
void open(int x, int y)
{
    if( x < 1 || x > BD_WD || y < 1 || y > BD_HT )     // 範囲外の場合
        return;
    if( g_open[x][y] )   //  既に開いている場合
        return;
    g_open[x][y] = true;
    if( !g_mine[x][y] && !g_nMine[x][y] ) {     // そこに爆弾が無く、周りにも爆弾が無い場合
        open(x-1, y-1);      // 周りも開く
        open(x, y-1);
        open(x+1, y-1);
        open(x-1, y);
        open(x+1, y);
        open(x-1, y+1);
        open(x, y+1);
        open(x+1, y+1);
    }
}

最初に引数 x, y の範囲チェックと既に開いているかのチェックを行っている。
これらは、後で説明再帰コールのためだ。

次に m_open[x][y] = true; を実行し、マス目 (x, y) を開いた状態にする。

ついで、この関数のキモである、再帰コールにより安全に開けるマス目を開ける処理。
8近傍の座標について、自分自身を呼ぶだけだ。

再帰コールで気をつけなくてはいけないのは、ちゃんと終了するかどうかだ。
open(x+1,y) で右隣りをコールし、その中で左隣りの open(x-1, y) をコールしたりすると、無限ループに陥ってしまう (実際にそうなるとスタックオーバーフローで例外が発生し、停止する)。

なので、関数の最初の方で、マス目が既に開いているかをチェックし、開いていれば何も処理をしないでリターンしているわけだ。

再帰処理は、初級者には難しい一種の壁のようなものだが、ちゃんと理解し、経験をつんでなんとかマスターして欲しい。
マスターすると、プログラム記述の自由度が増し、プログラミング能力が倍加すること間違いなしだ。

演習問題:

  1. デバッガを使って、open() 関数の処理をトレースしてみなさい。
  2. 階乗を計算する関数 fuct は fuct(x) = if( x が 1以下 ) return 1; else return x * fuct(x - 1); と定義できる。
    この定義どおりに階乗を計算する関数を定義しなさい。

ゲームクリアチェック

bool checkSweeped() は爆弾以外のマス目を全て開けたかどうかをチェックする関数だ。

//	爆弾箇所以外が全て開いていれば、成功
bool checkSweeped()
{
    for (int x = 1; x <= BD_WD; ++x) {
        for (int y = 1; y <= BD_HT; ++y) {
            if( !g_mine[x][y] && !g_open[x][y] )     //  爆弾が無いのに未だ開いていない
                return false;
        }
    }
    return true;
}

処理は単純だ。
for文で、x, y を回し、爆弾が無いのに未だ開いていないところが一箇所でもあれば false を返し、
そうでなければ true を返すだけだ。

main 関数

以上で準備が整ったので、いよいよ main関数の記述だ。

大枠

int main()
{
    srand((int)time(0));
    string buffer;
    cout << "MineSweeper version 0.001\n";
    cout << "writen by N.Tsuda\n\n";
    for (;;) {
        init_board();      //  盤面初期化
        bool sweeped = false;
        while (!sweeped) {     //  クリアされていない間ループ
            print_board();    // 盤面表示
            .....
        }
        print_board();    // 盤面表示
        if( sweeped )
            cout << "Good-Job !!!  you've sweeped all Mines in success.\n";
        else
            cout << "Oops !!! You've stepped on a Mine...\n\n";
        cout << "\ntry again ?[Y/N]";     // 再度ゲームするか確認
        cin >> buffer;
        if( buffer == "n" || buffer == "N" )
            break;
    }
    return 0;
}

main() 関数はちょっと長い(と言っても、わずか46行)ので、大枠の部分から説明。

最初に srand() を現在時刻で呼んで、乱数系列を変更する。
これを行わないといつも同じ乱数が発生する。
※ Tips:テストを行うときは、再現性があった方がいいので、この行はコメントアウトするといいぞ。

ついで、起動メッセージを表示し、forの無限ループに入る。

ループの中では最初に init_board() を呼んで盤面を初期化

クリアされたかどうかのフラグ(sweeped)を用意しておき、false に初期化しておく。

while( sweeped ) { } で1ゲーム内のループ。後で説明するが、爆弾にあたった場合は break; によりこのループを抜ける。

1ゲームのループを抜けたら、盤面を表示し、フラグを参照しクリアできたかどうかのメッセージを表示する。

最後に、再ゲームするかを訪ね、n または N が入力されたら break; し、プログラムを終了する。

1ゲームの処理

        while (!sweeped) {     //  クリアされていない間ループ
            print_board();
            int x, y;     // 開く場所
            for(;;) {
                cout << "where will you open ? [a-i][1-9]\n";
                cin >> buffer;
                if( buffer == "q" ) return 0; // "q" で強制終了
                if( buffer.size() < 2 ) continue;
                if( buffer[0] >= 'a' && buffer[0] <= 'i' )
                    x = buffer[0] - 'a' + 1;
                else if( buffer[0] >= 'A' && buffer[0] <= 'I' )
                    x = buffer[0] - 'A' + 1;
                else
                    continue;
                if( buffer[1] >= '1' && buffer[1] <= '9' )
                    y = buffer[1] - '0';
                else
                    continue;
                break;
            }
            open(x, y);
            if( g_mine[x][y] )       //  爆弾にあたってしまった場合
                break;
            sweeped = checkSweeped();
        }

変数 x, y を用意し、さらに内部の for文無限ループの中で cin によりプレイやが入力した文字列を取り込み、 座標指定文字の範囲チェックを行い変数 x, y に座標値を設定する。

x, y 座標値が正しく入力されたら、open(x, y) をコールし、指定マス目を開ける。
そこに爆弾があった場合は、ゲームオーバーとなる。
爆弾がなければ、checkSweeped() をコールし、爆弾以外の場所を全て開けたかどうかをチェックし、結果を sweeped に入れる。

関数化

本プログラムは一度しか呼ばない処理も関数化している。それにより全体の見通しがよくなり、動作確認も簡単になっている。
ひとつの関数の行数が多くなると、1画面で見えず、見通しが悪くなるので、意味のある一連の処理は積極的に関数化した方がよい。

本プログラムで使ったテクニック