技術文章> さくさく理解できる C/C++ コンソールアプリ入門 > スネークゲーム
「スネーク」と言っても、ダンボールに身を隠すゲームでは無い。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 を返す。
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 としている。
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ゲームのループを抜ける。
演習問題: