QMAの自習プログラムを作る その2

ソースコードの整理

まず WinMain の中が少し雑多になってしまったので、整理します。

#include <Windows.h>
#include <cstdio>
#include <cstring>

#define ID_OBUTTON 1001
#define ID_XBUTTON 1002

namespace myqma {

    wchar_t QuestionText[1000];
    int QuestionTextLength;
    int QuestionAnswer;
    LPCWSTR ClassName = L"wc_myqma";
    LPCWSTR ApplicationName = L"QMA自習プログラム";
    LPCWSTR QuestionFileName = L"question.txt";

    // ウィンドウプロシージャ
    LRESULT CALLBACK WndProc( HWND window, UINT message, WPARAM wp, LPARAM lp ) {
        switch( message ) {
        case WM_DESTROY:
            PostQuitMessage( 0 );
            break;
        case WM_COMMAND: // ボタンが押された時の動作
            if( LOWORD(wp)==ID_OBUTTON&&QuestionAnswer==0 || LOWORD(wp)==ID_XBUTTON&&QuestionAnswer==1 ) {
                MessageBox( window, L"正解!", ApplicationName, MB_OK );
            } else if( LOWORD(wp)==ID_OBUTTON&&QuestionAnswer==1 || LOWORD(wp)==ID_XBUTTON&&QuestionAnswer==0 ) {
                MessageBox( window, L"不正解...", ApplicationName, MB_OK );
            }
            break;
        case WM_PAINT: // 問題文を描画する
            {
                PAINTSTRUCT ps;
                HDC hdc;
                hdc = BeginPaint( window, &ps );
                TextOut( hdc, 10, 10, QuestionText, QuestionTextLength );
                EndPaint( window, &ps );
            }
            break;
        default:
            return DefWindowProc( window, message, wp, lp );
        }
        return 0;
    }

    // ファイルから問題文を読み込む
    bool LoadQuestionFile() {
        FILE *fp;
        wchar_t temporary[1000];
        _wfopen_s( &fp, QuestionFileName, L"rt, ccs=UTF-16LE" );
        if( fp == NULL ) {
            MessageBox( NULL, L"問題ファイルを読み込めませんでした", ApplicationName, MB_OK );
            return false;
        }
        fgetws( QuestionText, sizeof(QuestionText)/sizeof(QuestionText[0]), fp );
        QuestionTextLength = wcsnlen( QuestionText, sizeof(QuestionText)/sizeof(QuestionText[0]) );
        QuestionText[QuestionTextLength--] = L'\0';
        fgetws( temporary, sizeof(temporary)/sizeof(temporary[0]), fp );
        fclose( fp );
        if( temporary[0] == L'0' ) {
            QuestionAnswer = 0;
        } else if( temporary[0] == L'1' ) {
            QuestionAnswer = 1;
        } else {
            MessageBox( NULL, L"答えの定義が不正です", ApplicationName, MB_OK );
            return false;
        }
        return true;
    }

    HWND PrepareWindow( HINSTANCE instance, int cmdshow ) {
        // ウィンドウクラスの作成
        WNDCLASSEX windowclass;
        ZeroMemory( &windowclass, sizeof(windowclass) );
        windowclass.cbSize = sizeof(windowclass);
        windowclass.hInstance = instance;
        windowclass.lpszClassName = ClassName;
        windowclass.lpfnWndProc = WndProc;
        windowclass.hbrBackground = (HBRUSH)GetStockObject( WHITE_BRUSH );
        windowclass.hCursor = LoadCursor( NULL, IDC_ARROW );
        if( 0 == RegisterClassEx( &windowclass ) ) {
            MessageBox( NULL, L"ウィンドウクラスの登録に失敗", ApplicationName, MB_OK );
            return NULL;
        }
        // ウィンドウの作成
        HWND mywindow;
        mywindow = CreateWindow( ClassName,
            ApplicationName,
            WS_VISIBLE|WS_OVERLAPPEDWINDOW,
            CW_USEDEFAULT, CW_USEDEFAULT,
            600, 450,
            NULL, NULL, instance, NULL );
        CreateWindow( L"BUTTON", L"○", WS_CHILD|WS_VISIBLE,
            10, 100, 100, 100, mywindow, (HMENU)ID_OBUTTON, instance, NULL );
        CreateWindow( L"BUTTON", L"×", WS_CHILD|WS_VISIBLE,
            150, 100, 100, 100, mywindow, (HMENU)ID_XBUTTON, instance, NULL );
        return mywindow;
    }

    extern "C" int WINAPI wWinMain( HINSTANCE instance, HINSTANCE prev_instance, LPWSTR commandline, int cmdshow ) {
        HWND mainwindow;
        if( ! LoadQuestionFile() )
            return 1;
        mainwindow = PrepareWindow( instance, cmdshow );
        if( mainwindow == NULL )
            return 1;
        // メッセージループ
        MSG message;
        while( GetMessage( &message, NULL, 0, 0 ) ) {
            DispatchMessage( &message );
        }
        DestroyWindow( mainwindow );
        return message.wParam;
    }
}

コード部分を全て名前空間 myqma の中に収めました。WinMain 関数の前に extern "C" を付けていることに注意してください。 これがないとリンク時にエラーになります。

問題をファイルから読み込む部分は LoadQuestionFile 関数に、 ウィンドウを作成する部分は PrepareWindow 関数にそれぞれまとめています。 また変数名を一部変更し、後で変更することがありそうな文字列定数は上の方に集約しました。

次の目標設定

○×クイズができ、問題をファイルから読み込むこともできました。 ファイルに複数の問題を書いておいて、連続して出題できるようにしましょう。

まず最初に問題ファイルからデータを全部メモリ上の配列に読み込んでおきます。 配列のインデックスを指定して、そこから問題文を取り出して画面に表示させます。 (答え合わせする時も同様にインデックスを指定して正解データを取り出す。) 画面には「次」「前」のボタンを用意しておいて、ユーザーが操作できるようにします。 これらのボタンを押すとインデックスの値が変化し、問題を進めたり戻したりすることができるようにする、というわけです。

「次」「前」ボタンの追加

○×ボタンと同じように「次」「前」ボタンを追加します。 IDはそれぞれ ID_NEXTBUTTON (=1003), ID_BACKBUTTON (=1004) とします。 ○×ボタンもウィンドウの中央にくるように調整します。

        // ウィンドウの作成
        HWND mywindow;
        RECT windowrect = { 0, 0, 400, 300 };
        AdjustWindowRect( &windowrect, WS_OVERLAPPEDWINDOW, FALSE );
        mywindow = CreateWindow( ClassName,
            ApplicationName,
            WS_OVERLAPPEDWINDOW,
            CW_USEDEFAULT, CW_USEDEFAULT,
            windowrect.right-windowrect.left, windowrect.bottom-windowrect.top,
            NULL, NULL, instance, NULL );
        CreateWindow( L"BUTTON", L"○", WS_CHILD|WS_VISIBLE,
             80, 120, 100, 100, mywindow, (HMENU)ID_OBUTTON, instance, NULL );
        CreateWindow( L"BUTTON", L"×", WS_CHILD|WS_VISIBLE,
            220, 120, 100, 100, mywindow, (HMENU)ID_XBUTTON, instance, NULL );
        CreateWindow( L"BUTTON", L"次へ >>", WS_CHILD|WS_VISIBLE,
            400-90, 300-40, 80, 30, mywindow, (HMENU)ID_NEXTBUTTON, instance, NULL );
        CreateWindow( L"BUTTON", L"<< 前へ", WS_CHILD|WS_VISIBLE,
            400-90-90, 300-40, 80, 30, mywindow, (HMENU)ID_BACKBUTTON, instance, NULL );
        ShowWindow( mywindow, cmdshow|SW_SHOW );

上のソースではボタンを追加する以外にも少し変更を加えています。

まず AdjustWindowRect 関数ですが、これはクライアントエリアのサイズを正す為に用います。

クライアントエリアはウィンドウからタイトルバーと周りの枠を除いた、描画領域の部分です。 CreateWindow 関数でウィンドウサイズを指定する時にはタイトルバーと周りの枠を含めたサイズを指定しなくてはいけません。 クライアントエリアのサイズを正確に定める為には、タイトルバーと周りの枠の幅を計算する必要があります。 それをやってくれるのが AdjustWindowRect 関数で、RECT 型の構造体とウィンドウスタイル、メニューの有無の 3 つの引数を取ります。 RECT 型にクライアントエリアの領域座標を指定してやると、一緒に渡されたウィンドウスタイルとメニューの有無に 基いて、CreateWindowに渡すべき座標を計算してくれます。(結果は第 1 引数のRECT構造体を上書きします)

次に ShowWindow 関数ですが、これはウィンドウの表示形態を指定します。 その 1 でも少し説明した通り、WinMain 関数に渡された第 4 引数はこの関数に直接渡せます。 この引数を使うかどうかはプログラマーの自由ですが、ウィンドウをベースとしたアプリケーションの場合はユーザーの利便性の 為にも使用するべきでしょう。(固定サイズのウィンドウの場合は「最大化」をはじかなければいけない所は少し注意。) それと今まで CreateWindow 関数に WS_VISIBLE スタイルを指定して作ると同時に即座に表示させていましたが、 子ウィンドウ(ボタンコントロール)を全て作り終えてから ShowWindow で SW_SHOW を使って可視化するようにしました。

問題を配列に格納する

まずは問題文と答えをまとめて構造化しましょう。

    struct QuestionData {
        wchar_t Text[1000];
        int TextLength;
        int Answer;
    };

配列ですが、固定長配列はサイズの制限などが面倒なので、今回は std::vector を使います。
(注) std::vector の使い方はここでは解説しませんので、知らない方は各自で調べてください。 'stl vector' で検索すると良いでしょう。

    vector<QuestionData> Questions;
    int QuestionNumber = 0;

std:: を付けるのが面倒ならばソースの先頭に using namespace std; を記述しておきましょう。 あと vector をインクルードするのも忘れずに。 QuestionNumber という int 型の変数も作っていますが、これは出題中の問題の、配列のインデックスを表します。 「次」「前」ボタンが押されたら、この値をインクリメント/デクリメントさせます。

        case WM_COMMAND: // ボタンが押された時の動作
            if( LOWORD(wp)==ID_OBUTTON&&Questions[QuestionNumber].Answer==0 || 
                LOWORD(wp)==ID_XBUTTON&&Questions[QuestionNumber].Answer==1 ) {
                MessageBox( window, L"正解!", ApplicationName, MB_OK );
            } else if( LOWORD(wp)==ID_OBUTTON&&Questions[QuestionNumber].Answer==1 || 
                       LOWORD(wp)==ID_XBUTTON&&Questions[QuestionNumber].Answer==0 ) {
                MessageBox( window, L"不正解...", ApplicationName, MB_OK );
            } else {
                switch( LOWORD(wp) ) {
                case ID_NEXTBUTTON:
                    ++QuestionNumber;
                    if( QuestionNumber >= Questions.size() ) QuestionNumber = Questions.size() - 1;
                    InvalidateRect( window, NULL, TRUE );
                    break;
                case ID_BACKBUTTON:
                    --QuestionNumber;
                    if( QuestionNumber < 0 ) QuestionNumber = 0;
                    InvalidateRect( window, NULL, TRUE );
                    break;
                }
            }
            break;

QuestionNumber の境界条件に注意します。 これが取り得る範囲は配列の有効なインデックスです。 なので 0 から Questions.size()-1 までになります。

「次」「前」ボタンが押された処理の最後に InvalidateRect 関数を呼びだしています。 これはウィンドウに更新リージョンを強制的に追加します。 「次」「前」ボタンが押されたら問題を変化させなければいけませんが、 問題文の描画を行っているのは WM_PAINT メッセージの部分です。 その 1 で説明した通り、このメッセージはウィンドウに更新リージョンがないと発生しません。 InvalidateRect 関数を使って、ウィンドウ全体を再描画させるようにします。 第 2 引数に NULL を指定するとウィンドウ全体を更新リージョンに追加します。 第 3 引数は再描画の前に背景ブラシ(WNDCLASSEX.hbrBackground)で塗り潰すかどうかを指定します。 この塗り潰しの処理は BeginPaint 関数が呼び出された時に行われます。 ここで FALSE を指定してしまうと、変化前の問題文が消されずに残ってしまいます。

WM_PAINT 部分も配列を参照するように変更します。

        case WM_PAINT: // 問題文を描画する
            {
                PAINTSTRUCT ps;
                HDC hdc;
                hdc = BeginPaint( window, &ps );
                TextOut( hdc, 10, 10, Questions[QuestionNumber].Text, Questions[QuestionNumber].TextLength );
                EndPaint( window, &ps );
            }
            break;

次に LoadQuestionFile 関数を改造して、複数問題を読み込み配列に格納するようにしましょう。

    // ファイルから問題文を読み込む
    bool LoadQuestionFile() {
        FILE *fp;
        wchar_t temporary[1000];
        _wfopen_s( &fp, QuestionFileName, L"rt, ccs=UTF-16LE" );
        if( fp == NULL ) {
            MessageBox( NULL, L"問題ファイルを読み込めませんでした", ApplicationName, MB_OK );
            return false;
        }
        QuestionData item;
        Questions.clear();
        while( !feof( fp ) ) {
            if( NULL == fgetws( item.Text, sizeof(item.Text)/sizeof(item.Text[0]), fp ) )
                break;
            item.TextLength = wcsnlen( item.Text, sizeof(item.Text)/sizeof(item.Text[0]) );
            item.Text[item.TextLength--] = L'\0';
            if( NULL == fgetws( temporary, sizeof(temporary)/sizeof(temporary[0]), fp ) )
                break;
            if( temporary[0] == L'0' ) {
                item.Answer = 0;
            } else if( temporary[0] == L'1' ) {
                item.Answer = 1;
            } else {
                MessageBox( NULL, L"答えの定義が不正です", ApplicationName, MB_OK );
                fclose( fp );
                return false;
            }
            Questions.push_back( item );
        }
        fclose( fp );
        if( Questions.size() == 0 ) {
            MessageBox( NULL, L"問題が1つもありません", ApplicationName, MB_OK );
            return false;
        }
        return true;
    }

奇数行に問題文、偶数行に答え(0=○, 1=×)を記述するようにします。 EOF に当たるまで読み込みを繰り返します。 (fgetws が NULL を返した時も EOF とします) 問題が 1 つもない状態で続行してしまうと、QuestionNumber = 0 で初期化されているので、 Questions が空なのに Questions[0] を参照してしまいます。これを防ぐ為のトラップを最後に仕掛けてあります。

あとは question.txt に問題を記述していきます。

初めて作られたワクチンは天然痘のワクチンである
0
地球に最も近い恒星は月である
1
ドラえもんはタヌキ型ロボットである
1
日本の首都は東京である
0
マリアさんはかわいい
0

実行してみましょう。

うまくいきましたか? 下にここまでのソースファイルをまとめておきます。

src002.zip (4,713 バイト)

四択クイズを作る

次の目標は「四択クイズを作る」です。その為には

…などの必要があります。

ボタンを作るのは今迄と同様なので、やってみましょう。PrepareWindow 関数でボタンを 4 つ追加します。

        for( int i=0; i<4; i++ ) {
            CreateWindow( L"BUTTON", L"選択肢", WS_CHILD|WS_VISIBLE,
                50, 100+40*i, 300, 30, mywindow, (HMENU)(ID_FOURBUTTON+i), instance, NULL );
        }

これを実行すると当然…

○×ボタンと重なってしまいます。 ○×クイズの時は○×ボタンだけを表示し、四択クイズのときは 4 つの選択肢だけを表示する、という風にしなければいけません。 その区別をするために、QuestionData 構造体にデータを 1 つ追加します。

    struct QuestionData {
        int Type;
        wchar_t Text[1000];
        int TextLength;
        int Answer;
    };

Type == 0 の時は○×クイズ、1 の時は四択クイズであるということにしましょう。 ソースコードの可読性を上げるために #define で定義しておきます。

#define QTYPE_OX   0
#define QTYPE_FOUR 1

ボタンを見えなくしたい時は ShowWindow 関数で SW_HIDE を指定します。 (見えなくなるだけで、破棄はされません。) 逆に見えるようにしたいなら SW_SHOW を指定します。 この関数はウィンドウハンドルが必要なのですが、子ウィンドウ(ボタン)のウィンドウハンドルは PrepareWindow で作成している時に捨ててしまっています (CreateWindow 関数の戻り値を保存していない)。 もちろん CreateWindow の戻り値を保存しておいて使うという方法もあるのですが、 ID から HWND を取得できる関数があります。GetDlgItem 関数です。 第 1 引数に親ウィンドウのハンドル、第 2 引数に CreateWindow 関数の第 9 引数で渡した ID を指定すると、 その子ウィンドウのハンドルを得ることができます。

では、問題の出題準備を行う SetupQuestion 関数を作ります。

    void SetupQuestion( HWND window ) {
        ShowWindow( GetDlgItem( window, ID_OBUTTON ), SW_HIDE );
        ShowWindow( GetDlgItem( window, ID_XBUTTON ), SW_HIDE );
        for( int i=0; i<4; i++ )
            ShowWindow( GetDlgItem( window, ID_FOURBUTTON+i ), SW_HIDE );
        switch( Questions[QuestionNumber].Type ) {
        case QTYPE_OX:
            ShowWindow( GetDlgItem( window, ID_OBUTTON ), SW_SHOW );
            ShowWindow( GetDlgItem( window, ID_XBUTTON ), SW_SHOW );
            break;
        case QTYPE_FOUR:
            for( int i=0; i<4; i++ )
                ShowWindow( GetDlgItem( window, ID_FOURBUTTON+i ), SW_SHOW );
            break;
        }
        InvalidateRect( window, NULL, TRUE );
    }

まず最初に全てのボタンを SW_HIDE で見えなくしてしまいます。 その後、Questions[QuestionNumber].Type の値を見て、○×クイズならば○×ボタンを、四択クイズなら 4 つの選択肢ボタンを表示させます。 最後に InvalidateRect 関数を呼び出して問題文を再描画させるようにします。

「次」「前」ボタンの処理部分で SetupQuestion 関数を呼び出すようにします。

                case ID_NEXTBUTTON:
                    ++QuestionNumber;
                    if( QuestionNumber >= (int)Questions.size() ) QuestionNumber = Questions.size() - 1;
                    SetupQuestion( window );
                    break;
                case ID_BACKBUTTON:
                    --QuestionNumber;
                    if( QuestionNumber < 0 ) QuestionNumber = 0;
                    SetupQuestion( window );
                    break;

プログラムを起動した時も SetupQuestion を呼び出さなければいけない点に注意。

    extern "C" int WINAPI wWinMain( HINSTANCE instance, HINSTANCE prev_instance, LPWSTR commandline, int cmdshow ) {
        HWND mainwindow;
        if( ! LoadQuestionFile() )
            return 1;
        mainwindow = PrepareWindow( instance, cmdshow );
        if( mainwindow == NULL )
            return 1;
        SetupQuestion( mainwindow );
        // メッセージループ
        MSG message;
        while( GetMessage( &message, NULL, 0, 0 ) ) {
            DispatchMessage( &message );
        }

これでボタンが重ならないように出来ました。 この時点では QuestionData.Type に何も代入していません。(メモリのゴミが入っていることになります) なので実行してもおそらく全てのボタンが消えてしまうでしょう。

問題ファイルの方を、色々な問題形式に対応できるように改めます。 3 行で 1 問として、1行目に問題形式の番号を(0=○×、1=四択)、次の行に問題文、最後の行に正解と他の選択肢を記述することにします。 四択の場合選択肢を 4 つ用意する必要があるので、

1
2011年1月に開催されたサッカーAFCアジアカップで優勝した国はどこ?
日本,オーストラリア,韓国,カタール

このように , (カンマ) で区切って、最初の選択肢を正解、残りの選択肢をダミーとすることにします。

そういえば選択肢の文字列を保存しておく場所を作らなければいけませんね。

    struct QuestionData {
        int Type;
        wchar_t Text[1000];
        wchar_t Choices[4][100];
        int TextLength;
        int Answer;
    };

選択肢を分割して QuestionData.Choices に入れます。 C++ の string には C# や Java でいう String.Split にあたるものがないので、 自前でやらなければいけません。すこし面倒ですね… (boost にはそういうのがあるかもしれません。boost使ったことないから分からないですが…) ここでは 1 文字ずつ ',' があるか確認しながらコピーしていくようにしました。 文字列の中の ',' を検索してそこまでの文字列をコピーしていく、という方法もあると思います。

    // ファイルから問題文を読み込む
    bool LoadQuestionFile() {
        int i, j, k;
        FILE *fp;
        wchar_t temporary[1000];
        _wfopen_s( &fp, QuestionFileName, L"rt, ccs=UTF-16LE" );
        if( fp == NULL ) {
            MessageBox( NULL, L"問題ファイルを読み込めませんでした", ApplicationName, MB_OK );
            return false;
        }
        QuestionData item;
        Questions.clear();
        while( !feof( fp ) ) {
            fwscanf_s( fp, L"%d\n", &item.Type );
            if( NULL == fgetws( item.Text, sizeof(item.Text)/sizeof(item.Text[0]), fp ) )
                break;
            item.TextLength = wcsnlen( item.Text, sizeof(item.Text)/sizeof(item.Text[0]) );
            // 最後の改行を取り除く
            item.Text[item.TextLength--] = L'\0';
            if( NULL == fgetws( temporary, sizeof(temporary)/sizeof(temporary[0]), fp ) )
                break;
            int temp_len = wcsnlen( temporary, sizeof(temporary)/sizeof(temporary[0]) );
            switch( item.Type ) {
            case QTYPE_OX:
                if( temporary[0] == L'0' ) {
                    item.Answer = 0;
                } else if( temporary[0] == L'1' ) {
                    item.Answer = 1;
                } else {
                    MessageBox( NULL, L"答えの定義が不正です", ApplicationName, MB_OK );
                    fclose( fp );
                    return false;
                }
                break;
            case QTYPE_FOUR:
                for( i=0, j=0, k=0; i<temp_len; ++i ) {
                    if( temporary[i] == L',' ) {
                        item.Choices[j][k] = L'\0';
                        ++j;
                        if( j==4 ) break;
                        k=0;
                        continue;
                    } else if( temporary[i] == L'\0' || temporary[i] == L'\n' ) {
                        item.Choices[j][k] = L'\0';
                        ++j;
                        break; 
                    }
                    item.Choices[j][k++] = temporary[i];
                }
                if( j!=4 ) {
                    MessageBox( NULL, L"四択クイズの選択肢数が足りません", ApplicationName, MB_OK );
                    fclose( fp );
                    return false;
                }
                break;
            default:
                MessageBox( NULL, L"問題形式番号が不正です", ApplicationName, MB_OK );
                fclose( fp );
                return false;
            }
            Questions.push_back( item );
        }
        fclose( fp );
        if( Questions.size() == 0 ) {
            MessageBox( NULL, L"問題が1つもありません", ApplicationName, MB_OK );
            return false;
        }
        return true;
    }

問題形式の番号を読み込むのに fwscanf_s 関数を用いています。ここの書式で %d\n という風に指定しています。\n は行末まで読み込んで ストリームのポインタを次の行に進めるのに必要です。 %d だけでは次の fgetws 関数を呼び出した時に行の途中から読みこまれてしまいます。

ここまできて気付きましたが、これでは必ず最初の選択肢が正解になってしまいます。選択肢をシャッフルしなくてはなりません。

とりあえず外観だけでも完成させたいので、シャッフルは後回しにします。SetupQuestion で四択クイズの場合、 各ボタンに選択肢のテキストを表示するようにします。

        case QTYPE_FOUR:
            for( int i=0; i<4; i++ ) {
                ShowWindow( GetDlgItem( window, ID_FOURBUTTON+i ), SW_SHOW );
                SetWindowText( GetDlgItem( window, ID_FOURBUTTON+i ), Questions[QuestionNumber].Choices[i] );
            }
            break;

このように、ボタンの文字列を動的に変更したい場合は SetWindowText 関数を使います。 これを親ウィンドウに対して使うとタイトルバーの文字列を書換えることができます。

実行してみると…

選択肢ボタンの方に問題はないのですが、問題が長すぎてはみ出てしまっています…。 課題が山積みですね。

選択肢をシャッフルする

まずは選択肢のシャッフルを考えます。 QuestionData.Choices の中の要素を直接シャッフルしてもいいのですが、 文字列全体を何度も入れ替えるのは時間がかかってしまいます。 (それでも最近の高速なパソコンなら一瞬でしょうけど。) なので配列のインデックスだけをシャッフルするという方法を取ります。

現在は、ボタンの文字列は順番に QuestionData.Choices の各要素に対応しています。

ここに IndexShuffler というフィルターを挟み込んで、QuestionData.Choices の要素にアクセスする時に インデックスが入れ替わるようにします。

上の図の例でいうと、 ボタン 0 の文字列を設定するときに QuestionData.Choices[0] を見るわけですが、 ここで QuestionData.Choices[IndexShuffler[0]] のように間に挟み込むことで、 実際には QuestionData.Choices[2] をアクセスするようになるわけです。 つまり写像です。

IndexShuffler は内部にインデックス番号だけを保存しており、 シャッフルするのも整数値だけなので文字列を直接入れ替えるよりも 大幅に時間が短縮されます。

答え合わせの判定も楽にできます。押したボタンの番号を IndexShuffler にかけます。 QuestionData.Choices 側は正解を常に要素 0 に格納しているので、 押したボタンの番号の像が 0 ならば正解、と判定できます。 もしも Choices を直接シャッフルするならば、シャッフルした中から 文字列を比較しながら正解を探し出すという余計な作業が生じてしまいます。

シャッフルには random_shuffle を使います。vector<int> に要素番号を順に格納していき、 random_shuffle にかけます。

まずは vector<int> IndexShuffler; を宣言し、WinMain で初期化します。

        srand( GetTickCount() );
        for( int i=0; i<4; i++ ) IndexShuffler.push_back( i );

srand に用いている GetTickCount 関数は Windows が起動してからの経過時間をミリ秒単位で返します(unsigned int)。

SetupQuestion 関数の先頭で IndexShuffler の中身をシャッフルします。また、四択の選択肢をボタンに設定する時に IndexShuffler を挟み込みます。

    void SetupQuestion( HWND window ) {
        random_shuffle( IndexShuffler.begin(), IndexShuffler.end() );
        ShowWindow( GetDlgItem( window, ID_OBUTTON ), SW_HIDE );
        ShowWindow( GetDlgItem( window, ID_XBUTTON ), SW_HIDE );
        for( int i=0; i<4; i++ )
            ShowWindow( GetDlgItem( window, ID_FOURBUTTON+i ), SW_HIDE );
        switch( Questions[QuestionNumber].Type ) {
        case QTYPE_OX:
            ShowWindow( GetDlgItem( window, ID_OBUTTON ), SW_SHOW );
            ShowWindow( GetDlgItem( window, ID_XBUTTON ), SW_SHOW );
            break;
        case QTYPE_FOUR:
            for( int i=0; i<4; i++ ) {
                ShowWindow( GetDlgItem( window, ID_FOURBUTTON+i ), SW_SHOW );
                SetWindowText( GetDlgItem( window, ID_FOURBUTTON+i ), Questions[QuestionNumber].Choices[IndexShuffler[i]] );
            }
            break;
        }
        InvalidateRect( window, NULL, TRUE );
    }

正解の判定も容易に出来るので作ってしまいます。

        case WM_COMMAND: // ボタンが押された時の通知
            button = LOWORD(wp);
            switch( Questions[QuestionNumber].Type ) {
            case QTYPE_OX:
                if( button==ID_OBUTTON&&Questions[QuestionNumber].Answer==0 ||
                    button==ID_XBUTTON&&Questions[QuestionNumber].Answer==1 ) {
                    MessageBox( window, L"正解!", ApplicationName, MB_OK );
                } else if( button==ID_OBUTTON&&Questions[QuestionNumber].Answer==1 ||
                           button==ID_XBUTTON&&Questions[QuestionNumber].Answer==0 ) {
                    MessageBox( window, L"不正解...", ApplicationName, MB_OK );
                }
                break;
            case QTYPE_FOUR:
                if( ID_FOURBUTTON<=button && button<=(ID_FOURBUTTON+3) ) {
                    int number = button - ID_FOURBUTTON;
                    if( IndexShuffler[number] == 0 ) {
                        MessageBox( window, L"正解!", ApplicationName, MB_OK );
                    } else {
                        MessageBox( window, L"不正解...", ApplicationName, MB_OK );
                    }
                }
                break;
            }
            switch( LOWORD(wp) ) {
            case ID_NEXTBUTTON:
                ++QuestionNumber;
                if( QuestionNumber >= (int)Questions.size() ) QuestionNumber = Questions.size() - 1;
                SetupQuestion( window );
                break;
            case ID_BACKBUTTON:
                --QuestionNumber;
                if( QuestionNumber < 0 ) QuestionNumber = 0;
                SetupQuestion( window );
                break;
            }
            break;

写像の方向には十分注意しなければいけません。 ここでの IndexShuffler はボタンの番号から QuestionData.Choices の要素番号への写像です。 私は最初、うっかり

            case QTYPE_FOUR:
                if( ID_FOURBUTTON<=button && button<=(ID_FOURBUTTON+3) ) {
                    int number = button - ID_FOURBUTTON;
                    if( number == IndexShuffler[0] ) {
                        MessageBox( window, L"正解!", ApplicationName, MB_OK );
                    } else {
                        MessageBox( window, L"不正解...", ApplicationName, MB_OK );
                    }
                }
                break;

としてしまったのですが、Choices 側のインデックスを IndexShuffler にかけても滅茶苦茶な値しかでてきません。 逆の写像を求める場合、IndexShuffler の要素を走査して探し出さなければなりません。

では実行してみましょう。

「日本」をクリックして「正解」、その他の選択肢で「不正解」が表示されれば上手くいっています。

長い問題文を全部表示させる

長い問題文だとウィンドウのクライアントエリアからはみ出てしまうので、これを何とかします。 もちろんウィンドウを横にのばせば全部表示できますが、 QMA での 4 行並に長い問題文だととても見苦しくなります。

適切な位置で問題文に改行記号 (\n) を入れればいいのではないかと思われますが(私も最初そう考えました)、 TextOut 関数は改行記号に対応していませんでした。 改行記号を含む文字列を表示させようとすると下の図のようになります。

Hello, と world!! の間に改行を入れたのですが、改行されずに謎の記号が現れました。 \r\n(CR+LF) としても同じです。

じゃあ適当な位置で切り分けて TextOut を複数回呼び出さなきゃいけないのかー… と、そこへ便利な描画関数が登場。 DrawText 関数です。 こいつは改行記号に対応しているだけでなく、 描画領域の矩形を上手に指定してやるとそれに収まるように自動的に文字の折り返しもやってくれます。 その他センタリングなど便利な機能を色々搭載。最初からこれ使えばよかった…

        case WM_PAINT: // 問題文を描画する
            {
                PAINTSTRUCT ps;
                HDC hdc;
                hdc = BeginPaint( window, &ps );
                RECT r = { 15, 15, 400-15, 100 };
                DrawText( hdc, Questions[QuestionNumber].Text, Questions[QuestionNumber].TextLength, &r, DT_LEFT|DT_WORDBREAK );
                EndPaint( window, &ps );
            }
            break;

DT_WORDBREAK を指定することで長方形内に収まるように折り返しが行われます。センタリングは DT_CENTER です。これは連想クイズの再現に使えそうです。

上手く出来ました。 あとは問題文に明示的に改行を入れたいという場合もありますが、 問題文がはみ出ずに表示されるという当初の目的は達成されたので、 ここで一旦区切ります。

src003.zip (5,562 バイト)



作成日 2011/02/24 ... 最終更新日 2011/02/24
by zeroichi

QMAは株式会社コナミデジタルエンタテインメントの登録商標です