Code of Poem
Unknown programmer's programming note.

数当てゲームを作る

次に作るのはゲームです。ゲームと言ってもコンソールで数字を入力するだけのものです。ゲームのグラフィックスを扱う前に、もうちょっとC++のことを知っておきたいからです。単に構文を羅列するだけだとつまらないのでなにか良い題材はないか探していたらちょうど良さそうなのが見つかりました。数当てゲームの概要は次のとおりです。

数字を予想する単純なゲームを作って欲しい。ランダムな 1 から 100 の数字を決めて、プレイヤーに 10 回以内に当ててもらうゲームだ。プレイヤーには予想する都度、正解か間違いかを表示する。もしプレイヤーが間違っていれば、プレイヤーが予想した数字に応じて、大きすぎるか小さすぎるかを表示する。また、プレイヤーの前回の予想がどうだったかも表示する。ゲームはプレイヤーの予想が正しかった場合、もしくは回数の上限に達した場合に終了する。ゲームが終了したら、プレイヤーはもう一度プレイ開始できるようにする。

JavaScriptへの最初のダイブ

プログラミングの練習のために作ってみることにします。このゲームを作りながら覚えることは、

  1. 型と変数
  2. 演算子
  3. 制御文 (if-else文、switch文、while文、for文)
  4. 関数

この4つです。だいたいこの4つを覚えれば、簡単なプログラムなら力押しでなんとか作れるようになる気がします。また、多くの言語がこれらの機能を提供しているので、別の言語を使うことになっても何とかなる可能性もあります。

ゲームのルール

先の課題の文章からプログラムに必要な要素をリストアップします。

  1. ランダムに1から100の数字を決める。
  2. プレイヤーは10回まで入力できる。
  3. プレイヤーから数字を受け付ける。
  4. 入力された数字が最初に決めた数字と合っているか間違っているか表示する。
  5. 間違っていたら大きい小さいか表示する
  6. 前回の数字を表示する。
  7. 一致していたら終了する(ゲームクリア)。
  8. 10回目だったら終了する(ゲームオーバー)。
  9. 終了したらもう1回最初からプレイできる。

進め方としては、先に必要なC++言語の機能を導入してしまってから一気に作り始める方法か、順番に1ステップずつ進みながら必要になったタイミングでC++言語の機能を導入する方法があります。今回は後者のやり方で進めます。

ランダムに1から100の数字を決める

いきなり難しいです。まず「ランダム」とは何なのでしょうか。例えばサイコロを降ることを考えます。適当にサイコロを振ったら1から6までの数字のどれかが出ますがまともなサイコロなら100万回振ったら大体それぞれの目が16万回(100万/6)に近い数になるはずです。これはランダムと言っていいでしょう。これを採用することにします。つまり、1から100までの目があるサイコロを振ることを考えます。

「数を決める」というのはどういうことでしょうか。これはここだけみても分からず、後ろの部分をみると、(4)で決めた数とプレイヤーが入力した数字と大きいか小さいか比べています。決めた数字をあとで見ることが出来ないといけないことを意味します。この数をAと呼ぶことにします。

数A は 1から100までの目があるサイコロを振ってでた目

というのをC++のコードにすればいいわけです。サイコロの部分は難しいので後まわしにします。一旦、サイコロを振ると必ず42という目が出るものとしておきます。すると、

数A は 42

となります。C++にすると、

int A = 42;

と書くことが出来ます。これはAという名前の変数を定義して、その変数Aを42という値で初期化していると読むことが出来ます。有効なC++のコードですが、コーディングスタイルとして変数の名前を大文字で始めるのはあまり見かけないので小文字のaを使います。

int a = 42;

「int」というのは型の名前で、整数(integer)を表します。ただし数学の整数とは異なり、表現できる値の範囲に限界があります。2の補数表現で32ビットであることが多いです(-2,147,483,648から2,147,483,647)。整数を表現する型はにもありますが、1から100までならint型の変数に十分収まるのでこのようなケースではintがよく使われます。

「=」は変数の定義で使うと初期化になります。aという名前の変数を用意して、その値を42にしています。注意しておきたいのは、C++では変数の定義ではないところで=を使った場合は代入であり、場合によって意味や動作が全く異なる場合があることです。

「42」は整数リテラルです。当たり前のことに思えるかもしれませんが、コード中に数値を書くことが出来ます。

「;」は文の終わりを示します。int a = 42;は一つの文です。

とりあえず今の段階では、型の名前 変数の名前 = 初期値;と書くと、その変数の名前を使って後から値を使うことができると覚えておきます。

プレイヤーは10回まで入力できる

プレイヤーが何回入力したか覚えておく必要があります。最初は0回として、1回入力するたびに1ずつ増やしていって、10になったら終わりにするようにします。

これは前と同じで、変数に記録しておくことが出来ます。変数は定義したらあとで値を見るだけでなく、変更することも出来ます。

入力した回数 = 0;
入力した回数が10でなければ: --- A
    なにかする...(たぶん入力を受け付ける)
    入力した回数を1増やす
    Aにもどる

というような処理になるでしょう。これはC++ではwhile文を使って次のように書けます。

int count = 0;
while (count != 10) {
    // なにかする...(たぶん入力を受け付ける)
    count = count + 1;
}

int count = 0;は先と同じ変数の定義と初期化ですが、今度は値0に初期化しています。

while (条件)は繰り返しを意味します。条件の部分が真(TRUEとみなせる値、true、0以外の数など)である間、{から}までの部分を繰り返します。条件が偽(FALEとみなせる値、false、0、nullptr、NULL)となった時点で繰り返しを終了します。

条件count != 10は、左辺countの値と、右辺10を比較しています。!=は比較演算子です。左辺と右辺の値が等しく「ない」ときtrueを返します。つまり、countが10でなければtrueになります。

比較演算子は他にもあります。

==
左辺と右辺が等しいときtrue
!=
左辺と右辺が等しくないときtrue
<
左辺が右辺より小さいときtrue
<=
左辺が右辺より小さいか等しいときtrue
>
左辺が右辺より大きいときtrue
>=
左辺が右辺より大きいか等しいときtrue

これらはどれもよく使うので覚えておく必要があります。どれも直感的に分かるので自然と身につくとは思います。

気をつけたいのはx == 42と書くべきところを間違って=を一つ少なくしてしまって、x = 42と書いてしまうことです。これをやってしまうと、運が悪いと発見が遅れてなかなか間違いを見つけられなくなってしまうことがあります。

あとC++20から<==>というのも追加されるようです。

count = count + 1;の部分は変数countの値を1増やしています。最初は0で、1回繰り返すごとに1ずつ増えていくので、0、1、2、3、4、5、6、7、8、9、10となります。このうち、10のときはwhileが終了して本体は実行されないので、0から9まで、合計10回繰り返されるわけです。そして、whileが終了した時点でcountの値は10となっています。

「=」は代入演算子です。右辺の値を左辺に割り当てます。初期化のときの=とは区別されます。int型のような場合表面上の違いはないのですが、もっと複雑な型の場合、動作が異なってくることがあります。

「+」は算術演算子です。他にもあります。

+
足し算
-
引き算
*
掛け算
/
割り算
%
剰余(割った余り)

他にもビット演算の演算子があります。まずは上の5つを必ず覚える必要があります。

count = count + 1;のように自分自身に演算を行い代入を行うことは頻繁に行われるので省略した書き方が用意されています。count += 1;ともかけます。

さらに、1足したり引いたりすることは頻繁に行われるので、さらに短い書き方が用意されています。++countはcountの値を1増やします。

++を後ろに置くcount++という書き方もあります。意味がいくらか異なり、それが結果に影響する場合があります。C++プログラマの多くは正当な理由があって前置形の++countの方を好んで使います。

--countcount--もあります。**countや//countはありません。

以上のことを取り入れると、先のwhile文は次のようにも書き直せます。

int count = 0;
while (count < 10) {
    // なにかする...(たぶん入力を受け付ける)
    ++count;
}

こちらのほうが良いコードではないかと思います。

プレイヤーから数字を受け付ける

これを実現するためには、コンソールからプレイヤーの入力を読み取る必要があります。Hello Worldではstd::coutを使ってテキストをコンソールに書き出しました。その逆のstd::cinというものが用意されています。使い方は、

int guess_number;
std::cin >> guess_number;

となります。演算子>>に注目すると、std::cinというところから変数guess_numberに向かって流れ込んでくるようなイメージが出来ます。

何気ないコードですが、これは結構賢いことをやってくれてます。>>の右辺の型によって、プレイヤーが入力したテキストを数値に変換してくれるのです。例えばユーザーが55と入力したら、それは文字列"55"であって数値の55ではないはずです。しかし、右辺の方がintであることから、"55"が数として使えるか判断して、使えるならばそれにふさわしい値にして変数に割り当ててくれます。

気になるのは"xyz"のようなどうやっても数値として解釈できない文字列が入力されたときどう扱うべきかです。これは、

int guess_number;
std::cin >> guess_number;
if (std::cin.fail()) {
    // 数値として解釈できない入力だったときの処理
}

fail()という関数を呼び出すことで知ることが出来ます。こうすることで入力が正しくなかったことを検知できたとして、プログラムはどうするのが正しいのでしょうか。

  1. もう一度数字を入力してもらうように促す。
  2. プログラムを終了する。

当然、(1)の方がいいです。しかし、コードが複雑になるのを避けるため、今回はメッセージを出力して終了してしまうことにします。エラー処理のコードはたいてい複雑で長くなりがちです。

int guess_number;
std::cin >> guess_number;
if (std::cin.fail()) {
    std::cerr << "入力が間違ってます...終了します\n";
    std::exit(1);
}

こんな程度にしておきます。

std::cerrは標準エラーと呼ばれる場所にテキストを出力します。変な名前ですが結局だいたいコンソールに出力されます。UNIXライクな環境では出力先をリダイレクトすることでファイルに切り替えたりできるので、標準出力(これはstd::coutの出力先です)だけファイルにリダイレクトして、標準エラーはコンソールのままにしておくといったことが可能になります。std::exit()は標準ライブラリの関数です。呼び出すとプログラムを終了することが出来ます。括弧の中の1は引数で、プログラムを実行した呼び出し元、例えばシェル、に値を返します。エラー無しで通常終了した場合は0を返すのが良いですが、異常終了した場合は0以外の値を返したほうが良いです。

ここで出てきたif文については次の節で書きます。

入力された数字が最初に決めた数字と合っているか間違っているか表示する

2つの数値を比較するものはもう上で出てきました。比較演算子です。最初に決めた数字はaという名前でした。プレーヤーが入力した数字はguess_numberという名前でした。よってこの2つを比較すればいいことになります。

guess_number == a;

この比較の結果に応じて、合っているか間違っているか表示しないといけません。なので、ここで何とか比較の結果に応じて処理を振り分ける必要があります。これにはif-else文を使います。if-else文は

if (条件) {
    ... // 条件が真のときの処理
} else {
    ... // 条件が偽のときの処理
}

と書きます。条件式に先の数字の比較を使って結果に応じてメッセージを表示します。

if (guess_number == a) {
    std::cout << "当たりです!\n";
} else {
    std::cout << "はずれです。\n";
}

条件は必ずしも比較演算である必要はありません。2+2とか関数の戻り値でも真偽の判定ができるものなら何でもおけます。

間違っていたら大きい小さいか表示する

「間違っていたら」という条件付きなので、先のelseの中で行うと都合が良さそうです。また、ここでも入力された数字と最初に決めた数字を比較してメッセージを振り分けるので、if-elseを使うことになります。つまり、if-elseの中にまたif-elseが来ることになります。入れ子とかネストとか呼んだりします。

前の節のと合わせて書くと、

if (guess_number == a) {
    std::cout << "当たりです!\n";
} else {
    std::cout << "はずれです。\n";
    if (guess_number > a) {
        std::cout << "大きすぎます。\n";
    } else {
        std::cout << "小さすぎます。\n";
    }
}

2段くらいならよくあるのですが、あまりに深くネストすることは悪いコードとみなされかねませんので注意が必要です。

前回の数字を表示する

前回の数字を表示するためには前回の値をどこかで覚えておく必要があります。このためには最初のwhileより外側で記憶しておく変数を定義する必要があります。なぜなら、whileの中で変数を定義してしまうと、繰り返しのたびに初期化されてしまい、覚えておくことは出来ないからです。

また1回目に限っては前回の数字というものがありませんので、その点も考慮する必要があります。

int last_number;
while (count < 10) { // 最初のwhile
    ...

    if (guess_number == a) {
        std::cout << "当たりです!\n";
    } else {
        std::cout << "はずれです。\n";
        if (guess_number > a) {
            std::cout << "大きすぎます。\n";
        } else {
            std::cout << "小さすぎます。\n";
        }

        if (count > 0) {
            std::cout << "前回の数字:" << last_number << '\n';
        }
        last_number = guess_number;
    }

    ++count;
}

前回の結果を保存する変数last_numberには初期値をセットしていません。こうすると値は不定になります。バグのもとになり得るので基本的には変数の初期値はセットすべきなのですが、このlast_numberに関しては意味のある値がなく、適当に0とか-1とかにしても紛らわしいだけなのであえて未初期化のままにしてあります。

std::coutについて2点新しいことがあります。一つは<<を後ろにつなげて、連続して出力していることです。もう一つは、文字列ではなく数値をそのまま渡していることです。どちらもよく使うやり方です。std::coutはこういうことができるようによく考えられて作られてます。

一致していたら終了する

今のままだと、正解しても入力が10回になるまで繰り返してしまいます。一致していたら何らかの方法で繰り返しを終了しなければいけません。

簡単なやり方は2通り考えられます。ひとつはbreak文を使ってwhileから強制的に抜け出ることです。

while (count < 10) {
    ...
    if (guess_number == a) {
        std::cout << "当たりです!\n";
        break;
    } else {
        ...
    }
    ...
}

break;のところに来ると、即座に一番直近の繰り返しから抜け出します。上での場合、countの値に関係なく繰り返しから抜け出ます。

もうひとつは、ゲームの続行を管理する変数を用意して、whileの条件に追加することです。

bool right_on = false;

right_onという名前のbool型の変数を定義してfalseで初期化しています。boolは変数の型で、tureかfalseのどちらかの値を取ることが出来ます。この変数をwhileの条件に追加します。

while (count < 10 && !right_on) {
    ...
    if (guess_number == a) {
        std::cout << "当たりです!\n";
        right_on = true;
    } else {
        ...
    }
    ...
}

&&!論理演算子です。もうひとつ||があるのでこれもセットで覚えておきます。

A && B AND Aがtrue かつ Bがtrueなら true
A || B OR Aがtrue または Bがtrueなら true
!A NOT Aを反転する Aがtrueならfalse Aがfalseならtrue

よく似た演算子に&|があります。これはビット演算であって、紛らわしいことに意味が少し似てるのですが、結果は全然違ってきます。

どちらでもよさそうですが、今回はbreak文を使うことにします。

10回目だったら終了する

while文の条件で10回目に繰り返しが終了することになっているので特にやることはないです。

終了したらもう1回最初からプレイできる

先に決めておくこととして、もう1回プレイするかプレイヤーに問い合わせるのか、無条件でもう1回プレイさせるのか、ということがあります。課題文には何も書いてないのでどちらでもよさそうです。無条件でもう1回プレイさせるほうが簡単です。while文を使って、

while (1) {
      ... // ゲーム
}

と書けます。これは無限ループと呼ばれます。ただこうすると終了できなくなってしまうのでソフトウェアとしては欠陥品になってしまいます。なのでもう1回プレイするか問い合わせることにします。

char play_again = 'y';
while (play_again == 'y') {
    // ゲームを始める
    // ...

    std::cout << "もう1回プレイしますか? [y/n] ";
    std::cin >> play_again;
}

このコードはどこか違和感があります。whileの上で、変数を宣言して手動で'y'に設定しています。次の行のwhileの条件を最低1回はパスしないといけないのでこうする必要があります。

最低1回は実行して、あとは条件で繰り返すというようなパターンのためにdo-while文というのが用意されています。

char play_again;
do {
    // ゲームを始める
    // ...

    std::cout << "もう1回プレイしますか? [y/n] ";
    std::cin >> play_again;
} while (play_again == 'y');

たいして変わらないように見えるかもしれませんが、個人的にはこちらの方がいいです。

char型が出てきました。多くの場合8ビット長で、-127から127までの値を取ることができることが保証されます(2の補数表現を使わないアーキテクチャのために-128からではなく-127からになっているそうです)。'y'は文字リテラルです。単一引用符で囲みます。

std::cin >> play_again;はだいたいアルファベット1文字分読み取ります。だいたいというのは日本語文字を入力した場合などおかしなことになるからです。

プレイヤーがyと入力すると繰り返しは終了します。この入力読み込みには問題があります。yyyyとか入力されると、最初の一文字のみがstd::cinから読み込まれ、残りの3文字は残ったままになってしまい、次の数字入力で誤作動を起こします。そのため次のような処理が必要になります。

char play_again;
do {
    // ゲームを始める
    ...
    std::cout << "もう1回プレイしますか? [y/n] ";
    std::cin >> play_again;
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
} while (play_again != 'n');

std::cin.ignore()を使って、1文字読み込んだ後、後ろにまだ文字が残っていれば改行を入力したところまで捨ててしまいます。こうすれば次のstd::cinから読み込むときは空の状態になっているはずです。このテクニックは上の方で省略した、数字を読み込むときのエラー処理にも使えます。

全部まとめる

これで必要なパーツは揃いました。全部くっつけて1つのプログラムにしてみます。

#include <iostream>
#include <cstdlib>
#include <limits>
using namespace std;

int main()
{
    char play_again;
    do {
        int a = 42; // ランダムな数字
        int count = 0;
        int last_number;
        while (count < 10) {
            int guess_number;
            cin >> guess_number;
            cin.ignore(numeric_limits<streamsize>::max(), '\n');
            if (cin.fail()) {
                cerr << "入力が間違ってます...終了します\n";
                exit(1);
            }
                        
            if (guess_number == a) {
                cout << "当たりです!\n";
                break;
            } else {
                cout << "はずれです。";
                if (guess_number > a) {
                    cout << "大きすぎます。\n";
                } else {
                    cout << "小さすぎます。\n";
                }
                
                if (count > 0) {
                    cout << "前回の数字:" << last_number << "\n";
                }
                last_number = guess_number;
            }
            ++count;
        }
        cout << "もう1回プレイしますか? [y/n] ";
        cin >> play_again;
        cin.ignore(numeric_limits<streamsize>::max(), '\n');
    } while (play_again == 'y');
}

これをコンパイル、ビルドすると実際に動かすことが出来ます。

新しいことはusing namespace std;のところです。これをやるとstd::の部分を省略できるようになります。

こうやって見ると、数を入力するところで入力のエラー処理の雑さが目立ちます。また、if文が結構深くネストしているのも気になります。関数に分割することで少し改善されるとは思います。

ランダムな数字を選ぶ

まだ一つ仕事が残ってました。ランダムな数字を選ぶところです。今は42という数字に固定してしまっています。テストのときは便利ですがこれではゲームにならないので、ちゃんとランダムっぽい数字を選ぶようにします。

ランダムな数字は疑似乱数と呼ばれ、std::rand()関数で得ることが出来ます。C++にはもっといい疑似乱数を生成する方法があるのですが、std::rand()方が使い方が簡単なので今回はこちらを使っておきます。

std::rand()は0からRAND_MAXという予め決められた範囲の数を返します。欲しいのは1から100までなので調整しなければなりません。手軽なのは出た数の剰余を取ることです。

int a = std::rand() % 100 + 1; // 1から100までのランダムな数?

しかしこれには問題があります。例えば、仮に欲しい数が1から3まででRAND_MAXが6だったとします。

r: rand()の数 r % 3 + 1
0 1
1 2
2 3
3 1
4 2
5 3
6 1

これをみて分かるとおり、1が多く出ることになります。なので別の方法を考えないといけません。「Accelerated C++」という本に良い方法が載っています、ということを紹介するにとどめておきます。

基本的な方針はrand関数が返した数が、欲しい数より大きかったらそれは捨ててしまってもう一度やり直すというものです。愚直に欲しい数より大きかったら捨ててしまうとやると、rand関数のやり直し回数が多くなりすぎて無駄すぎるので、調整をします。RAND_MAXをnで割ってそれをバケツのサイズとします。こうするとサイズRAND_MAX/nのサイズのバケツがn個手に入ります。次にrand関数で数を取得します。例えばそれをボールの個数だとみなすとしたら、バケツに順番に入れていきます。すると満杯になったバケツの数が欲しかった疑似乱数になります。ただし、満杯になったバケツの数がn以上になってしまったら、再度rand関数で数を取ってきてやり直します。これで数は[0, n)の範囲に均等に分布するはずです。

参考

本文中でも随所にリンクを貼っておいたcppreference.comは英語ですがすごく役に立つサイトです。ググる前にこのサイトを検索すると時間が節約できると思います。

日本語の本だと「プログラミング言語C++ 第4版」が頼りになります。ちょっと値段が高いのと分厚いのが難点ですがそれだけの価値はあります。翻訳はかなり訳語に気になるところがあるものの全体的には読みやすいです。