テトリスの作り方(其の三)

概要:自作プログラム『SPACE TETRIS』の作り方を丁寧に解説!

■メッセージ処理とスレッド

今回は一定時間ごとに処理する方法としてタイマーではなくスレッドを使います。
スレッドが担う役割はゲーム全体の動作制御です。
従って、新しく作るスレッドを動作制御スレッド、
もともとあるメッセージ処理を担当しているスレッドをメインスレッドと呼ぶことにしましょう。
なぜわざわざ難しいスレッドを使うことにしたのか?と
憤慨している読者もいるかもしれませんが、高い自由度が欲しかったから……
というのは建前で実戦経験がないので使ってみたかったからです(笑)。
たしかに難解かつ複雑にはなってしまいましたが、もくろみ通り高い自由度は得られました。
タイマーでは無理だったかもしれない処理も可能となったのです。
スレッドの可能性を探る意味でもここは何が何でもスレッドに挑戦してみましょう!
ただしスレッドの基礎は自分で勉強して下さい……。

■動作制御スレッドと無関係なメッセージ処理

メインスレッドと動作制御スレッドは時に密接に連携します。
本来は由々しき自体ですが、色々やっているうちに連携せざるを得なくなったというのが本音です。
今回の最難関は間違いなくこの連携にあります。

とりあえずこの難題は後に回すとして、
動作制御スレッドと無関係なメッセージ処理を見ていきましょう。

○WM_CREATE

ファイルから背景画像(andromeda.bmp)を読み込みます。
もしも読み込めなければ即座にプログラムを終了させます。
読み込めた場合は画像のサイズを取得して、
同じサイズのメモリデバイスコンテキストを作成します。

描画は裏画面に完成画像を描いてから表画面へ一度だけ転送するという方法で行います。

そして乱数を使うために srand() 関数を実行しておきます。

他にもスレッドを作るなどの処理がありますが、これらは後で解説します。

○WM_PAINT

描画処理は Paint() 関数にまとめてあります。
Paint() 関数にメモリデバイスコンテキストのハンドルを渡すことで、
裏画面への描画が行われます。
Paint() 関数から復帰したら、裏画面を表画面へ転送すればディスプレイに表示されます。

Paint() 関数の具体的な定義は最後に解説することにします。
デザインは個人の自由なので あまり気にする必要ありませんから。

○WM_KEYDOWN

bl=NULL; を実行している意味を考えて下さい。
BOOL 型変数 bl はローカル変数なので、
何の値も代入されなかった場合は不定値です。
不定値のままでは最後の処理
if(bl) InvalidateRect(hWnd,NULL,FALSE);
で困るんです。

また return 0; ではなく break; でメッセージ処理を抜けているのは
もしかしてシステムに必要なメッセージであった可能性を考慮して
DefWindowProc(hWnd,uMsg,wParam,lParam); に処理してもらうためです。

○その他

WM_MUTEX と WM_KEYDOWN の VK_DOWN , VK_ESCAPE , VK_RETURN については後ほど解説します。

#include<windows.h>
#include<time.h>

// winmm.lib をリンクする
#pragma comment(lib,"winmm")

DWORD playTime=0;       // プレイ時間
BOOL GameOver=FALSE;    // TRUE となるのはゲームオーバーからリプレイするまで

#define MUTEX_NAME    "MutexObject of SPACE TETRIS"    // ミューテックスオブジェクトの名前

#define WM_MUTEX    WM_APP    // メインスレッドにミューテックスの所有権取得を要求するメッセージ

LRESULT CALLBACK WindowProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam)
{
    HDC hdc;
    PAINTSTRUCT ps;
    BOOL bl;
    DWORD beforeTime,dwID;
    HBITMAP hBitmap;
    static BITMAP bitmap;
    static HDC hBackDC,hMemDC;
    static HANDLE hThread;    // 動作制御スレッドのハンドル
    static HANDLE hMutex;     // ミューテックスオブジェクトのハンドル

    switch(uMsg) {
        case WM_CREATE:
            // 背景画像をロードする
            hBitmap=(HBITMAP)LoadImage((HINSTANCE)GetWindowLong(hWnd,GWL_HINSTANCE),
                "andromeda.bmp",IMAGE_BITMAP,0,0,LR_LOADFROMFILE);
            if(hBitmap==NULL){
                MessageBox(hWnd,"プログラムを終了します","背景画像がありません",MB_OK);
                SendMessage(hWnd,WM_DESTROY,0,0);
                return 0;
            }
            GetObject(hBitmap,sizeof(BITMAP),&bitmap);
            hdc=GetDC(hWnd);
            hBackDC=CreateCompatibleDC(hdc);
            SelectObject(hBackDC,hBitmap);
            DeleteObject(hBitmap);

            // メモリデバイスコンテキストを作る
            hBitmap=CreateCompatibleBitmap(hdc,bitmap.bmWidth,bitmap.bmHeight);
            hMemDC=CreateCompatibleDC(hdc);
            ReleaseDC(hWnd,hdc);
            SelectObject(hMemDC,hBitmap);
            DeleteObject(hBitmap);

            srand((unsigned)time(NULL));
            hMutex=CreateMutex(NULL,TRUE,MUTEX_NAME);    //最初の所有者はメインスレッド

            NextPiece(TRUE);
            hThread=CreateThread(NULL,0,ThreadProc,hWnd,0,&dwID);    // スレッドを作る
            return 0;
        case WM_DESTROY:
            DeleteDC(hBackDC);
            DeleteDC(hMemDC);
            CloseHandle(hThread);
            CloseHandle(hMutex);
            PostQuitMessage(0);
            return 0;
        case WM_MUTEX:
            WaitForSingleObject(hMutex,INFINITE);    // ミューテックスの所有権を取得する
            return 0;                                // (動作制御スレッドを待機させる)
        case WM_PAINT:
            hdc=BeginPaint(hWnd,&ps);
            BitBlt(hMemDC,0,0,bitmap.bmWidth,bitmap.bmHeight,hBackDC,0,0,SRCCOPY);
            Paint(hMemDC);
            BitBlt(hdc,0,0,bitmap.bmWidth,bitmap.bmHeight,hMemDC,0,0,SRCCOPY);
            EndPaint(hWnd,&ps);
            return 0;
        case WM_KEYDOWN:
            bl=NULL;
            switch(wParam){
                case VK_LEFT:
                    bl=MovePiece(PIECE_LEFT);
                    break;
                case VK_RIGHT:
                    bl=MovePiece(PIECE_RIGHT);
                    break;
                case VK_DOWN:
                    bl=MovePiece(PIECE_DOWN);
                    if(!bl) ReleaseMutex(hMutex);
                    break;    // ↑現在移動中のブロックを固定させる為に動作制御スレッドの待機解除
                case VK_SPACE:
                    bl=TurnPiece();
                    break;
                case VK_ESCAPE:    // 一時停止
                    SuspendThread(hThread);
                    beforeTime=timeGetTime();
                    MessageBox(hWnd,"ゲームを再開しますか?","一時停止中",MB_OK);
                    ResumeThread(hThread);
                    playTime-=(timeGetTime()-beforeTime);
                    break;
                case VK_RETURN:    // ゲームオーバーしていれば新しいゲームを始める
                    if(GameOver){
                        WaitForSingleObject(hMutex,INFINITE);  // 新しく作る動作制御スレッドを待機させる
                        CloseHandle(hThread);                  // ハンドルを閉じてもスレッドは終了しない
                        hThread=ReInitialize(hWnd);            // 再初期化(動作制御スレッドも新しく作る)
                    }
                    break;
            }
            if(bl) InvalidateRect(hWnd,NULL,FALSE);
            break;
    }
    return DefWindowProc(hWnd,uMsg,wParam,lParam);
}

■動作制御スレッドを作る

動作制御スレッドはメインスレッドの WM_CREATE メッセージ処理の時に作ります。
処理の開始アドレスとしては ThreadProc() 関数を指定します。
また、その関数に与える引数はウィンドウのハンドルとします。

hThread=CreateThread(NULL,0,ThreadProc,hWnd,0,&dwID);    // スレッドを作る

スレッドはここで指定した関数を抜けると終了します。

■ミューテックス

ミューテックスとはシステムに唯一のオブジェクトです。
一般にタスクの同期などに使われますが、スレッドの同期にも使えます。
ミューテックスには所有権という概念があって、
所有権を持つスレッドだけに処理を実行させる……などの仕掛けが可能となります。
なぜなら所有権はただ一人しか持つことができないからです。

ミューテックスもメインスレッドの WM_CREATE メッセージ処理の時に作ります。
その時、最初の所有者はメインスレッドになるようにします。
というよりも、ミューテックスの所有権はある状況を除いてはずっとメインスレッドが所有し続けます。
ある状況というのが連携したい時です。

#define MUTEX_NAME  "MutexObject of SPACE TETRIS"  // ミューテックスオブジェクトの名前

hMutex=CreateMutex(NULL,TRUE,MUTEX_NAME);    //最初の所有者はメインスレッド

※ミューテックスの詳細は自分で勉強して下さい

■動作制御スレッド

動作制御スレッドの基本的な動作は「一定時間ごとに現在移動中のピースを一つ下に移動させる」事です。
しかし、下に移動させようとしたらできなかった……という場合には、
ピースを固定して、行が埋まっているかを調べて……ゲームオーバーしていないかも調べて……
という具合にやらなければならないことは沢山あります。

■スレッドを待機させる

スレッドを待機させる方法には単純に眠らせる方法が考えられますが、
メインスレッドからの介入により強制的に走らせたいこともあるので
Sleep() 関数を使うのはNGです。
というのも起こすことができないからです。

ここでミューテックスが活躍します。
待機関数の一つである WaitForSingleObject() 関数を使えば、
ミューテックスの所有権を取得するか、
または指定の時間を過ぎればミューテックスの所有権を持っていなくても待機を解除します。


メインスレッドが介入したい時はミューテックスの所有権を放棄するだけです。
すると動作制御スレッドが所有権を取得し、待機を解除します。
しかし、それ以外の介入が必要ない通常は、メインスレッドがミューテックスの所有権を持ち続け、
動作制御スレッドはタイムアウトによって待機を解除することを基本とします。

signal=WaitForSingleObject(hMutex,sleep-progress);

■時間を計測する

timeGetTime() 関数はシステムが起動してからの時間をミリ秒単位で返す高精度タイマーです。
この関数を使うためには winmm.lib をリンクする必要があります。

// winmm.lib をリンクする
#pragma comment(lib,"winmm")

timeGetTime() 関数を使えば前回処理したときからの経過時間が計算できます。
希望の経過時間に達していないのであれば、必要な時間だけスレッドを待機させます。

DWORD beforeTime=timeGetTime();
DWORD sleep=1000;
DWORD progress,signal;
                            // ↓メインスレッドからの介入がない限り
progress=timeGetTime()-beforeTime;    // タイムアウトによって待機を解除する
if(progress<sleep) signal=WaitForSingleObject(hMutex,sleep-progress);

■メインスレッドからの介入

動作制御スレッドの待機はメインスレッドからの介入によっても解除されます。
従って、WaitForSingleObject() 関数から制御が返ってきたとしても
希望した時間だけ待機したとは限りません。
そこで、どれだけの時間を待機したか計算します。
この時間の総和がプレイ時間に相当します。
今回は一分ごとに待機時間を減らしていくようにしました。
だんだんとピースの落下速度を速くさせるためです。

DWORD minute=0;

progress=timeGetTime()-beforeTime;     // ←メインスレッドからの介入により
playTime+=progress;               // タイムアウトを待つことなく待機を解除した可能性を考慮
minute+=progress;
if(minute>=60*1000 && sleep>100){
  sleep-=100;    // 一分ごとに待機時間を減らしてゆく
  minute=0;
}
beforeTime=timeGetTime();

■VK_DOWN

メインスレッドが介入したいのは、プレイヤーが故意にピースを固定させようとしたときです。
つまり、移動できない状況なのに、
それでも下方向にピースを移動させようとしたときです。
この時は、動作制御スレッドの周期処理が始まるのを待っていては遅すぎます。
もしも介入せずに待つとしたら、固定したつもりができていなかった……ということが多々起こります。

介入する手段は簡単。
ミューテックスの所有権を解放するだけです。
すると動作制御スレッドが所有権を取得して、
すぐさま WaitForSingleObject() は制御を返します。
つまり、待機を解除します。

case VK_DOWN:
  bl=MovePiece(PIECE_DOWN);
  if(!bl) ReleaseMutex(hMutex);
  break;    // ↑現在移動中のブロックを固定させる為に動作制御スレッドの待機解除

■メインスレッドから介入された時の後処理

メインスレッドがミューテックスの所有権を解放して、
動作制御スレッドが所有権を取得した場合を考えてみましょう。
このまま動作制御スレッドが所有権を持ち続けたなら
WaitForSingleObject() 関数による待機はなくなってしまいます。
それでは困るので、ミューテックスの所有権を解放して、
メインスレッドに所有権を即座に取得するよう要求します。
即座にというのは、動作制御スレッドが取得するより早くという意味です。
従って、SendMessage() 関数により、その旨を伝えましょう。
PostMessage() 関数ではいけません。

■WM_MUTEX

WM_MUTEX メッセージはメインスレッドがミューテックスの所有権を取得することを
要求されている時に送られてきます。

ミューテックスの所有権取得を目的とする場合にも WaitForSingleObject() 関数を使います。
もちろん取得可能な時に送られてくるはずのメッセージですからタイムアウト時間は 0 秒でもいいのですが、
バグチェックの意味も込めて、タイムアウト時間を無限に……
つまり、ミューテックスの所有権取得によってのみ制御を返すようにしましょう。

case WM_MUTEX:
  WaitForSingleObject(hMutex,INFINITE);  // ミューテックスの所有権を取得する
  return 0;                     // (動作制御スレッドを待機させる)

■メインスレッドから介入された時の後処理(続き)

それでは、メインスレッドに介入されたかどうかはどのように判断すればよいのでしょうか?
WaitForSingleObject() 関数の戻り値は待機を解除した原因です。
戻り値が WAIT_OBJECT_0 ならばミューテックスの所有権を取得したことで待機を解除したことを意味します。
従って、その時はメインスレッドからの介入があったと判断できます。

signal=WaitForSingleObject(hMutex,sleep-progress);

if(signal==WAIT_OBJECT_0){          // メインスレッドからの介入により待機を解除した
  ReleaseMutex(hMutex);           // ミューテックスの所有権を解放
  SendMessage(hWnd,WM_MUTEX,0,0);  // メインスレッドにミューテックスの所有権取得を要求
}

■変数 signal に初期値を与えた理由

"WAIT_OBJECT_0" は数値 "0" の別名として定義されています。
また、WaitForSingleObject() 関数は if(progress<sleep) が偽なら実行されません。
従って、最初の WaitForSingleObject() 関数は実行されない可能性があります。
ということは、変数 signal が初期値のまま比較される可能性があるということです。
(初期値=)不定値との比較は好ましくありません。
以上の理由により変数 signal に
他のどの WaitForSingleObject() 関数の戻り値とも一致しない値 "1" を初期値として与えました。

■現在移動中のピースを一つ下に移動させる

もしも下に移動できたら再描画メッセージを発効するだけです。
逆に移動できなかったら、ピースを固定して、埋まっている行があるかを調べて、
埋まっている行があるのならば削除して、時間をおいてから行を詰めます。
そして点数も加算します。

■時間をおく

行を削除したら詰めなければなりません。
しかし、演出として行が削除されたことをプレイヤーに見せたい……。
従って、時間をおく必要があります。

これがメインスレッドのタイマーだとしたら、次の処理の時に行を詰めるなどの方法が考えられますが、
かなり自由度の低い方法だと思いませんか?
例えば、タイマーの間隔よりも少ない時間だけ時間をおきたい場合は、
別のタイマーを作るなどする必要があります。

しかし動作制御スレッドならば単純に希望の時間だけ眠らせる事によって時間をおくことができます。

Sleep(500);

この方法はメッセージループと結びついているメインスレッドで行ってはいけません。
システムはプロシージャから返ってくる時間が遅いとビジーと判断してしまいますし、
眠っている間はプレイヤーの入力や描画なども含めて
一切の処理を実行することができなくなってしまいます。

さて、眠る前にいくつかやっておくべきことがあります。
真っ先に考えられるのが再描画メッセージを発行することです。
動作制御スレッドは眠ってもメインスレッドは眠らないので、
メインスレッドに再描画指示を出せば処理してくれます。

InvalidateRect(hWnd,NULL,FALSE);
Sleep(500);

あとは点数計算もしておくことにしましょう。
行を詰めた時じゃなくて削除した時に加算した方が自然に感じられるはずです。

さらに、これは見逃しがちですが、
移動していたピースも消しておきましょう。
削除した行のどこかには移動していたピースがあるはずですが、
ピースを固定しても次のピースを作成するまで現在のピースは残ったままですので、
削除したはずの行にブロックが一つだけ残っているように見えてしまいます。

以上の処理をまとめると以下のようになります。

HWND hWnd=(HWND)lpParameter;
int x,y,line;

if(!MovePiece(PIECE_DOWN)){  // 現在移動中のブロックが下段に達したら↓
  PieceToField();
  line=DeleteLine();
  if(line>0){
    for(y=0;y<PIECE_HEIGHT;y++){
      for(x=0;x<PIECE_WIDTH;x++){
        piece[x][y]=0;
      }
    }
    if(line==4) score+=1000;
    else score+=(line*100);
    InvalidateRect(hWnd,NULL,FALSE);
    Sleep(500);
  }
}

ところで、眠っている間も現実の時間は流れているので、
次の WaitForSingleObject() 関数で待機する時間は
眠った時間だけ短くなるという事に注意して下さい。
合計して最低 sleep 時間だけ眠る」とも言えます。

今思うとおかしな仕様にしてしまいましたね……。
WaitForSingleObject() 関数に sleep を直接渡して、
途中で眠った時間に関係なく、ある時間だけ待機させる方が良いでしょう。
この修正は皆さんへの課題とします。

■起きてから

眠りから覚めたら行を詰めましょう。
そしてゲームオーバーしていないかを調べます。
ゲームオーバーしていないのであれば新しいピースを作ります。

■ゲームオーバー

ゲームオーバーしたときはゲームオーバーフラグを立てて、
ミューテックスの所有権を解放、
そしてミューテックスのハンドルを閉じます。
ミューテックスは全てのハンドルを閉じない限り破棄されません。
今、閉じようとしているハンドルはメインスレッドとは別の、新しく開いたものですから、
これを閉じただけではミューテックスは破棄されません。

最後にメッセージボックスを表示して、
制御が返ってきたらスレッドを終了させます。

HANDLE hMutex=OpenMutex(MUTEX_ALL_ACCESS,FALSE,MUTEX_NAME);

if(field[6][0] || field[7][0]){  /* ゲーム終了 */
  GameOver=TRUE;
  ReleaseMutex(hMutex);  // ミューテックスの所有権を解放
  CloseHandle(hMutex);   // 全てのハンドルを閉じない限りミューテックスは破棄されない
  MessageBox(hWnd,"このメッセージボックスを閉じた後に\n"
    "  Enterキーを押せば……新しいゲームが始まります\n"
    "  ×ボタンを押せば……終了します","GAME OVER",MB_OK);
  return 0;  // ゲーム終了でスレッドは破棄する
}

DWORD score=0;    // 獲得点数

// 動作制御スレッド
DWORD WINAPI ThreadProc(LPVOID lpParameter)    // lpParameter:ウィンドウのハンドル
{
    HWND hWnd=(HWND)lpParameter;
    HANDLE hMutex=OpenMutex(MUTEX_ALL_ACCESS,FALSE,MUTEX_NAME);
    DWORD beforeTime=timeGetTime();
    DWORD sleep=1000;
    DWORD minute=0;
    DWORD progress,signal;
    int x,y,line;

    while(1){                                 // ↓メインスレッドからの介入がない限り
        progress=timeGetTime()-beforeTime;    // タイムアウトによって待機を解除する
        if(progress<sleep) signal=WaitForSingleObject(hMutex,sleep-progress);
        progress=timeGetTime()-beforeTime;    // ←メインスレッドからの介入により
        playTime+=progress;                   // タイムアウトを待つことなく待機を解除した可能性を考慮
        minute+=progress;
        if(minute>=60*1000 && sleep>100){
            sleep-=100;    // 一分ごとに待機時間を減らしてゆく
            minute=0;
        }
        beforeTime=timeGetTime();

        if(!MovePiece(PIECE_DOWN)){    // 現在移動中のブロックが下段に達したら↓
            PieceToField();
            line=DeleteLine();
            if(line>0){
                for(y=0;y<PIECE_HEIGHT;y++){
                    for(x=0;x<PIECE_WIDTH;x++){
                        piece[x][y]=0;
                    }
                }
                if(line==4) score+=1000;
                else score+=(line*100);
                InvalidateRect(hWnd,NULL,FALSE);
                Sleep(500);
                ShiftLine(line);
            }
            if(field[6][0] || field[7][0]){    /* ゲーム終了 */
                GameOver=TRUE;
                ReleaseMutex(hMutex);    // ミューテックスの所有権を解放
                CloseHandle(hMutex);     // 全てのハンドルを閉じない限りミューテックスは破棄されない
                MessageBox(hWnd,"このメッセージボックスを閉じた後に\n"
                    "    Enterキーを押せば……新しいゲームが始まります\n"
                    "    ×ボタンを押せば……終了します","GAME OVER",MB_OK);
                return 0;    // ゲーム終了でスレッドは破棄する
            }
            NextPiece(FALSE);
        }
        InvalidateRect(hWnd,NULL,FALSE);

        if(signal==WAIT_OBJECT_0){             // メインスレッドからの介入により待機を解除した
            ReleaseMutex(hMutex);              // ミューテックスの所有権を解放
            SendMessage(hWnd,WM_MUTEX,0,0);    // メインスレッドにミューテックスの所有権取得を要求
        }
    }
    return 0;
}

スレッドの話はもう少し続きます。


前へ 3/4 次へ

戻る / ホーム