技術文章さくさく理解できる C/C++ コンソールアプリ入門 > スネークゲーム


 

 

スネークゲーム
Nobuhide Tsuda
May-2014

概要

「スネーク」と言っても、ダンボールに身を隠すゲームでは無い。1970年代後半のマイコン黎明期に作られた蛇が餌を食べるというゲームだ。
実は筆者はオリジナルのゲームをプレイしたことが無い。ので、wiki などを参考に適当に実装してみた。 細部のルールはオリジナルと異なっているかもしれない。その点はご容赦いただきだい。
参照:「へび ゲーム」「Snake(video game)」

スネークゲーム ソースコード

遊び方

画面に @**** で表現され、前方に進む蛇(スネーク)が表示される。これを上下左右カーソルキーまたは hjkl で操作し、餌($)を食べる。
外周の壁または自分の胴体に衝突するとゲームオーバーとなる。
一定時間経つか、餌を食べると胴体が徐々に長くなっていくので、壁や胴体を回避するのがだんだん難しくなっていく。

解説

画面設計

デフォルトのコンソール画面は、横80カラム、縦25行だ。
これらの値は CONS_WD, CONS_HT というシンボルを割り当てている。

スクリーンショットを見ればわかるように、1行面はスコアを表示する部分、最下行はメッセージを表示する部分としている。
残りの部分に外周を描画している。
なので、蛇が移動する部分は、CONS_WD - 2カラム, CONS_HT - 4行 となる。

座標構造体

蛇や餌の位置は、CurPos 構造体で表す。CurPos は2つの整数値を持つ型で、以下のように定義している。

typedef pair<int, int> CurPos;          // 座標値タイプ定義

std::pair<int, int> は2つの int型メンバ変数を持つ構造体だ。
struct CurPos { int first, int second; デフォルトコンストラクタ;コピーコンストラクタ; … }; と定義するのと同等で、はるかに簡単に書ける。
2つの型を同時に扱いたいが、構造体を定義するまでもないときに便利だ。

餌関連

// 乱数で食料位置を生成し、foods に追加する
// 重なりチェックは行わない
void add_foods(vector<CurPos> &foods, int cnt)
{
    for (int i = 0; i < cnt; ++i) {
        int x = rand() % (CONS_WD - 2) + 1;
        int y = rand() % (CONS_HT - 4) + 2;
        foods.push_back(CurPos(x, y));
    }
}

add_foods() は餌を生成する関数だ。

餌は std::vector<CurPos> で管理されている。vector は動的にサイズを増やすことのできる配列クラスだ。
乱数で x, y座標を生成し、それらをペアにして、push_back() で配列末尾に追加する。

// 餌を画面に表示
void print_foods(const vector<CurPos> &foods)
{
    setColor(COL_GREEN);
    for (uint i = 0; i < foods.size(); ++i) {
        setCursorPos(foods[i].first, foods[i].second);
        cout << "$";
    }
}

print_foods() は餌を画面に表示する関数だ。

まず色を指定し、for文で餌の個数(foods.size() で取得できる)だけループし、setCursorPos(x, y) で餌の位置にカーソル移動し、"$" を描画する。
foods は(動的)配列なので foods[i] で各餌の位置を取得できる。
で、位置は x, y座標のペアなので、first, second でそれどれを取得できる。

// (x, y):スネークの頭位置
// return: 餌を食べた場合は true を返す
bool check_foods(vector<CurPos> &foods, int x, int y)
{
    for (int i = (int)foods.size(); --i >= 0; ) {
        if( foods[i].first == x && foods[i].second == y ) {
            foods.erase(foods.begin() + i);     // 要素を削除
            return true;
        }
    }
    return false;
}

check_foods() は蛇が餌を食べたかどうかをチェックする関数だ。
餌を食べた場合は、餌の位置情報を foods から削除し、true を返す。

餌を食べたかどうかは、for文で餌の個数(foods.size() で取得可能)ループし、x, y 座標値が一致しているかどうかで判定する。

座標値が一致していれば、erase() で要素を削除し、true を返す。


erase() の引数はイテレータで指定する。イテレータの詳しい説明は省略するが、foods.begin() + i で、i 番目の要素へのイテレータとなる。
詳しくはググって調べてね。

画面表示

void print_field()
{
    setColor(COL_YELLOW, COL_YELLOW);
    setCursorPos(0, 1);
    for (int i = 0; i < CONS_WD; ++i) {     // 上部外周描画
        cout << " ";
    }
    for (int y = 2; y < CONS_HT - 2; ++y) {  // 左右外周描画
        setCursorPos(0, y);
        cout << " ";
        setCursorPos(CONS_WD - 1, y);
        cout << " ";
    }
    setCursorPos(0, CONS_HT - 2);    // 最下行のひとつ上
    for (int i = 0; i < CONS_WD; ++i) {     // 下部外周描画
        cout << " ";
    }
    setColor(COL_YELLOW, COL_BLACK);    // 背景黒
    for (int y = 0; y < CONS_HT - 4; ++y) {      // 中央のフィールド部分描画
        setCursorPos(1, y + 2);
        for (int x = 0; x < CONS_WD - 2; ++x) {
            cout << " ";
        }
    }
    setCursorPos(0, CONS_HT - 1);    // 最下行描画
    for (int i = 0; i < CONS_WD - 1; ++i) {
        cout << " ";
    }
}

print_field() はフィールドを画面に表示する関数だ。

最初に setColor(COL_YELLOW, COL_YELLOW) で文字色・背景色を黄色に設定する。
次いで、setCursorPos(0, 1); で、外周の左上位置にカーソルを移動し、空白を80個表示する。 こうすると、背景色が黄色に設定されているので、黄色のブロックが1行表示される。

同様に、カーソル位置を設定し、左右外周、下部外周を描画している。

setColor(COL_YELLOW, COL_BLACK) で、背景色を黒に指定し、中央のフィールド部分、最下行のメッセージ表示部分に空白を表示することで、 その部分をクリアしている。

void print_score(int score, int bodyLength)
{
    setColor(COL_WHITE);
    setCursorPos(0, 0);
    cout << "SCORE:";
    cout.width(6);        // 表示幅指定
    cout << score;
    cout << "\tBODY:";
    cout.width(6);
    cout << bodyLength;
}

print_score() はスコアと蛇長を表示する関数だ。

テキスト色を白に設定し、カーソルを画面左上(0, 0)に設定し、スコアと蛇長を表示する。

単純に cout で表示すると、スコアの桁数が減った時に前のスコアが消去されないので、cout.width(6) で表示桁数を指定している。
蛇長についても同様だ。

蛇関連

// スネークの頭、胴体部分を表示
void print_snake(const deque<CurPos> &snake)
{
    // 胴体部分を描画
    setColor(COL_BLUE);
    for (uint i = 1; i < snake.size(); ++i) {
        setCursorPos(snake[i].first, snake[i].second);
        cout << "*";
    }
    // 重なった場合にも頭部を描画するために、最後に頭部を描画
    setColor(COL_VIOLET);
    setCursorPos(snake[0].first, snake[0].second);
    cout << "@";    //  頭部
}

print_snake() は蛇を描画する関数だ。

最初に胴体部分を描画し、あとから頭部分を描画している。
頭を後に描画するのは、頭と胴体がぶつかった時に、頭部分が画面に表示されるようにするためだ。
先に頭を描画すると、後で描画される胴体に上書きされてしまうからだ。

蛇の頭と胴体座標は const deque<CurPos> &snake に格納されている。operator[] で各座標を取り出し、 色、座標を指定し、cout で胴体と頭を描画している。

// スネーク位置、胴体長を更新
void update_snake(deque<CurPos> &snake, int x, int y, bool extend)
{
    snake.push_front(CurPos(x, y));
    if( !extend ) {     // 胴体が伸びない場合は、末尾位置に空白を表示し、胴体を消す
        setCursorPos(snake.back().first, snake.back().second);
        cout << " ";
        snake.pop_back();
    }
}

update_snake() は蛇を前にひとつ進める関数だ。最後の引数の extend が偽の場合は、胴体の末尾座標を取り除く。

座標を deque の最初に追加するのは push_front() を使用する。

胴体末尾を削除するには、その座標に空白を表示して、胴体の * を消去し、
pop_back() を使って、座標データを deque から削除する。

// 壁またはスネークの胴体とぶつかったかどうかをチェック
bool collapsed(const deque<CurPos> &snake)
{
    int x = snake[0].first;
    int y = snake[0].second;
    if( x <= 0 || x >= CONS_WD - 1 || y <= 1 || y >= CONS_HT - 2 )
        return true;       // 周辺の壁に衝突
    for (uint i = 1; i < snake.size(); ++i) {
        if( snake[i].first == x && snake[i].second == y )
            return true;       // 自分自身と衝突
    }
    return false;      //  衝突無し
}

collapsed() は蛇の頭が外周、または自分の胴体と衝突したかどうかをチェックする関数だ。

snake[0] で頭座標を取り出し、それが外周位置であれば、true を返す。

次に蛇の各胴体部分座標と比較し、一致していれば true を返す。
ループを抜けた場合は、衝突無しなので false を返す。

// 胴体方向かどうかをチェック
// return:  胴体がなければ true を返す
bool check_body(const deque<CurPos> &snake, int dx, int dy)
{
    if( snake.size() < 2 ) return true; //  胴体が無い場合
    return snake[0].first + dx != snake[1].first
                || snake[0].second + dy != snake[1].second;
}

check_body(snake, dx, dy) は dx, dy 方向に頭の次の胴体があるかどうかをチェックする関数だ。
これは、蛇が180度方向転換出来なくするためのものだ。

(必要ないかもしれないが)最初に、胴体がなければ衝突しようがないので true を返す。

次に、頭の次の胴体座標と比較し、一致していなければ true を、一致していれば false を返す。

全体の構造

int main()
{
    srand((int)time(0));    //  乱数系列初期化
    for (;;) {
        1ゲームの初期化
        for(int cnt = 0; ; ++cnt) {
            単位時間毎の処理
            Sleep(200);       // 0.2秒 ウェイト
        }
        再ゲームを行うかユーザに尋ね、No なら break;
    }
    return 0;
}

メインプログラムは上記のように二重のforループにより構成される。

外側のループはゲームを繰り返すためのものだ。 1ゲームが終了すると、再ゲームを行うかどうかを訪ね、N が押されれば break; によりループを抜け、プログラムを終了する。

内側のループは1ゲーム内での繰り返し処理だ。
多くのプラットフォームでは1秒間に60フレームの処理を行う。これを 60FPS(Frame per Sec)と呼ぶ。
通常はそれをタイマー割り込みで処理するのだが、コンソールアプリでそれを行うのは面倒なので、ループの最後に Sleep(200) を実行し、 擬似的に 5FPS としている。

1ゲームの初期化処理

        int score = 0;    //  スコアを0に初期化
        int dx = 0, dy = 1;     // 速度ベクター
        int x = CONS_WD / 2;    // 初期位置座標
        int y = CONS_HT / 2;
        deque<CurPos> snake;       // スネーク座標情報、front が頭
        snake.push_front(CurPos(x, y-2));
        snake.push_front(CurPos(x, y-1));
        snake.push_front(CurPos(x, y));

上記はスコアと蛇の初期化の部分。

スコアのための変数を宣言し、0に初期化している。

蛇の進行方向は dx, dy で表す。初期状態では下に向かって進むので、それぞれを 0, +1 で初期化している。

std::deque は配列型のコンテナクラス。std::vector とほぼ同じだが、vector は末尾挿入削除は O(1) だが、先頭への挿入削除は O(N) と遅い。
それに対して deque は先頭への挿入削除も O(1) と高速だ。
蛇は1マスずつ移動するので、座標値をひとつづつシフトしてもいいのだが、それより新しい座標値を先頭に追加した方がコードが簡潔になる。

        vector<CurPos> foods;     //  フード位置配列
        add_foods(foods, N_FOOD);            //  フードを追加
        print_field();
        print_foods(foods);
        print_snake(snake);
        print_score(score, snake.size());
        int eating = 0;       //  餌を食べたフラグ

上記は残りの初期化処理部分だ。

餌位置ベクターの変数 foods を宣言し、N_FOOD 個の餌をランダム位置に追加する。

次いで、フィールド、餌、蛇、スコアを描画し、餌を食べたフラグを 0 に設定して、1ゲーム内のループに入る。

            // キーによる方向転換処理
            if( (isKeyPressed('H') || isKeyPressed(VK_LEFT)) && check_body(snake, -1, 0) ) {
                dx = -1;
                dy = 0;
            } else if( (isKeyPressed('J') || isKeyPressed(VK_DOWN)) && check_body(snake, 0, 1) ) {
                dx = 0;
                dy = 1;
            } else if( (isKeyPressed('K') || isKeyPressed(VK_UP)) && check_body(snake, 0, -1) ) {
                dx = 0;
                dy = -1;
            } else if( (isKeyPressed('L') || isKeyPressed(VK_RIGHT)) && check_body(snake, 1, 0) ) {
                dx = 1;
                dy = 0;
            }
            x += dx;
            y += dy;

上記は、キーによる方向転換処理と、蛇座標更新処理。

キーが押されているかどうかは isKeyPressed(key) で判定する。 また、180度の方向転換は禁止なので check_body() を呼んで、そうでないことを確認している。
VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT はそれぞれ、上下左右カーソルキーを表す。
方向転換の場合は dx, dy をその方向になるよう設定している。

dx, dy をそれぞれ x, y に足し込むことで、蛇の頭を移動している。

            bool extend = cnt%TERM == TERM-1;     // TERM回に1回胴体を伸ばす
            if( extend ) {
                score += POINT_TIME;      //  一定期間毎にポイント追加
                add_foods(foods, 1);           // 餌を一個追加
            }
            if( eating ) {
                --eating;
                extend = true;       // 餌を食べた場合は、胴体を徐々に伸ばす
            }
            update_snake(snake, x, y, extend);      //  スネーク位置、胴体長更新

extend は蛇の胴体を伸ばすかどうかのフラグ。1ゲーム内ループが TERM 回ループする度に true に設定している。
その場合は score に POINT_TIME を加算し、add_foods(foods, 1) をコールして餌を1個増加させている。

蛇が餌を食べた場合は eating が正の整数に設定されているので、それをデクリメントし、extend フラグを true に設定している。
この処理により蛇の胴体が eating に設定された回数だけ伸びることになる(ちゃんと理解できたかな?)。

次いで、update_snake() をコールして、snake が保持する蛇座標情報を更新している。

            if( check_foods(foods, x, y) ) {
                cout << (char)0x07;          //  ビープ音
                eating = EXT_FOOD;          // 餌を食べると胴体が伸びる
                score += POINT_FOOD;
            }

蛇座標を更新したので、次いで check_foods() をコールし、蛇が餌を食べたかどうかをチェックしている。
餌を食べた場合は、0x07 を出力することでビープ音を鳴らし、eating に EXT_FOOD を代入することで、蛇の胴体長を EXT_FOOD だけ徐々に長くする。 そして、score に POINT_FOOD(値は100) を足し込んでいる。

            print_foods(foods);
            print_snake(snake);
            print_score(score, snake.size());
            if( collapsed(snake) ) {    // 蛇の頭が外周または胴体に衝突したか?
                cout << (char)0x07 << (char)0x07 << (char)0x07;      // ビープ音*3
                break;
            }

蛇位置更新、餌チェックなど、1ループ内の更新処理が終わったので、print_foods(), print_snake(), print_score() を呼んで画面表示を更新する。
そして、collapsed() を呼んで蛇の頭が外周または胴体に衝突したかをチェックし、衝突した場合はビープ音を3回鳴らし、 break; で1ゲームのループを抜ける。

演習問題:

  1. 蛇の胴体色を青と黄色交互にしなさい。
  2. スペシャルな餌を用意し、これを食べるとポイントが+500入るようにしなさい。
  3. 一定時間毎に、蛇がウンチ(&)をするようにしなさい。で、蛇がそのウンチを食べるとゲームオーバーとしなさい。
  4. add_foods() は蛇や他の餌と同じ位置に餌を生成する場合がある。そうなっても問題が無いかどうかを検証しなさい。
  5. add_foods() を、蛇や他の餌と同じ位置に餌を生成しないよう修正してみなさい。
  6. std::pair を使用せず、struct CurPos を自分で定義・実装してみなさい。

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