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

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

■再試合

もしもゲームオーバーになっていれば新しいゲームを始めることができます。
それには色々な状態をプログラム起動直後の状態に戻す必要があります。

まず、ミューテックスの所有権が確実にメインスレッドにあるようにしなければいけません。
メインスレッドが所有権を解放して、動作制御スレッドが取得、
ゲームオーバーで解放しただけで終了したかもしれません(メインスレッドに取得を要求していない)。
あるいは“まだ”解放していないかもしれませんし、
そもそもメインスレッドは所有権を解放していなかったかもしれません。

どちらにせよ WaitForSingleObject() 関数を使って
メインスレッドに確実にミューテックスの所有権を取得させましょう。
確実に、というのはタイムアウト時間を無限にすればよさそうです。
しかし、動作制御スレッドが“まだ”解放していないという特殊な状況を除けば、
すんなりと所有権を取得できるはずです。

次に、新しいスレッドを作るために、古いスレッドのハンドルを閉じます。
ハンドルを閉じてもスレッドは終了しませんが、
ゲームオーバーしているのですから、遠くない未来に自ら終了するはずです。

そうしたら、色々な変数を再初期化して、新しいスレッドも作ってくれる ReInitialize() 関数を呼びましょう。
ReInitialize() 関数は変数 GameOver の値も FALSE にしてくれます。

case VK_RETURN:  // ゲームオーバーしていれば新しいゲームを始める
  if(GameOver){
    WaitForSingleObject(hMutex,INFINITE);  // 新しく作る動作制御スレッドを待機させる
    CloseHandle(hThread);            // ハンドルを閉じてもスレッドは終了しない
    hThread=ReInitialize(hWnd);         // 再初期化(動作制御スレッドも新しく作る)
  }
  break;

// 再初期化する
// 戻り値:新しいスレッドのハンドル
HANDLE ReInitialize(HWND hWnd)    // hWnd:ウィンドウのハンドル
{
    for(int y=0;y<FIELD_HEIGHT;y++){
        for(int x=0;x<FIELD_WIDTH;x++){
            field[x][y]=0;
            fColor[x][y]=0;
        }
    }
    score=0;
    playTime=0;
    GameOver=FALSE;

    DWORD dwID;         // piece[][] , pColor[][] , location , next[][] , nColor[][]
    NextPiece(TRUE);    // が NextPiece() で初期化される
    return CreateThread(NULL,0,ThreadProc,hWnd,0,&dwID);    // ゲーム終了後にスレッドは破棄されている
}

■スレッドの切り替え

スレッドはいつ切り替わるかが分からないのが難点です。
そして、これが問題となるのは他のスレッドと連携したい時です。
例えば、ゲームオーバーした時ですが、以下の箇所でスレッドが切り替わって、
再試合の処理が開始された場合を考えてみましょう。

if(field[6][0] || field[7][0]){  /* ゲーム終了 */
  GameOver=TRUE;

  // ここでスレッド切り替え

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

この時、まだミューテックスの所有権を解放していないので、
以下の箇所で待機させられることになります。

case VK_RETURN:  // ゲームオーバーしていれば新しいゲームを始める
  if(GameOver){
    WaitForSingleObject(hMutex,INFINITE);  // 新しく作る動作制御スレッドを待機させる

    // 待機

    CloseHandle(hThread);            // ハンドルを閉じてもスレッドは終了しない
    hThread=ReInitialize(hWnd);         // 再初期化(動作制御スレッドも新しく作る)
  }
  break;

するとこちらのスレッドは待つしかないので、
再びスレッドが切り替わり、
そして、いつかはもう一方のスレッドがミューテックスの所有権を解放するでしょう。

それでは、次の箇所でスレッドが切り替わった場合はどうでしょうか?
もう一方のスレッドではやっぱり再試合の処理が開始されたとします。

if(field[6][0] || field[7][0]){  /* ゲーム終了 */
  GameOver=TRUE;
  ReleaseMutex(hMutex);  // ミューテックスの所有権を解放

  // ここでスレッド切り替え

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

この時はすんなりとミューテックスの所有権を取得することができるので、
再試合の処理が待機させられることはありません。

case VK_RETURN:  // ゲームオーバーしていれば新しいゲームを始める
  if(GameOver){
    WaitForSingleObject(hMutex,INFINITE);  // 新しく作る動作制御スレッドを待機させる
    CloseHandle(hThread);            // ハンドルを閉じてもスレッドは終了しない
    hThread=ReInitialize(hWnd);         // 再初期化(動作制御スレッドも新しく作る)
  }
  break;

しかし、スレッドがまだ終了していないのにハンドルを閉じようとしています。
大丈夫なのでしょうか?
大丈夫です。
ハンドルを閉じてもスレッドは終了しません。
そしていつかは自ら終了するでしょう。

ところで、VK_RETURN が二回以上実行される可能性についても考慮してみましょう。
Enter キーを連打すれば、それもあり得る?
いいえ、あり得ません。
たとえ処理途中でスレッドが切り替わったとしても、
戻ってくるのは以前実行していた場所です。
そして VK_RETURN は一度でも処理を終了すれば、
もう一度ゲームオーバーにならない限り実行できないように仕組んであります。
何度 Enter キーを連打したところで最初のメッセージ以外は if(GameOver) により
再試合の処理を開始することができません。

■最初のピース

動作制御スレッドが作られるのはゲームを開始する時です。
それはプログラムを起動してから初めての時かもしれませんし、再試合の時かもしれません。

動作制御スレッドが作られてから初めての仕事は待機することです。
待機を解除するとピースを下方向に移動させたり、
ピースが固定されれば新しいピースを作ったりします。

従って、動作制御スレッドが作られる前に最初のピースを作っておく必要があります。
そして、新しいピースの一端が見えるように初期位置を決める必要があるでしょう。

// 動作制御スレッド
DWORD WINAPI ThreadProc(LPVOID lpParameter)  // lpParameter:ウィンドウのハンドル
{
  DWORD beforeTime=timeGetTime();
  DWORD sleep=1000;
  DWORD progress,signal;

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


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


// 再初期化する
HANDLE ReInitialize(HWND hWnd)  // hWnd:ウィンドウのハンドル
{
                // piece[][] , pColor[][] , location , next[][] , nColor[][]
  NextPiece(TRUE);  // が NextPiece() で初期化される
  return CreateThread(NULL,0,ThreadProc,hWnd,0,&dwID);  // ゲーム終了後にスレッドは破棄されている
}


/* 次のブロックへ */
void NextPiece(BOOL first)  // first:ゲーム開始から最初の呼び出しか否か
{
  location.x=5;
  location.y=-3;
}

■一時停止

ゲームを一時停止させるとは、ゲームを操作できないようにする事です。
ゲームを操作しているのはプレイヤーと周期処理です。

キーやマウスなどの入力メッセージを処理するのはメインスレッドですが、
メインスレッドの全ての処理を停止させるわけにはいきません。
例えば、再描画が行われないという状況は異常です。
そこで MessageBox() 関数を使いましょう。
メッセージボックスウィンドウは入力メッセージを独占して独自のプロシージャで処理します。
いつものプロシージャにメッセージが届かないのですからゲームを操作できないことになります。
ただし、それ以外のメッセージはいつものプロシージャに届けられるので、
再描画が行われないといった状況にはならず万事解決です。

周期処理を担当しているのは動作制御スレッドです。
こちらは停止して困る処理がないのでスレッドごと中断させましょう。
中断には SuspendThread() 関数を使います。
再開には ResumeThread() 関数を使います。
現在処理しているその場所で中断、再開します。
ところで、中断している間も待機関数の待機時間はカウントされ続けます。
つまり、残りの待機時間より多く中断すれば、再開と同時に待機が解除されます。

一時停止で問題になるのはプレイ時間の計算です。
プレイ時間は現実の時間がどれだけ経過したかで計算していました。
もちろん一時停止中も現実の時間は止まることがありません。
従って、どれだけの時間を一時停止していたか記録して、
現実の時間より計算されたプレイ時間から引けば正しいプレイ時間が得られそうです。

case VK_ESCAPE:  // 一時停止
  SuspendThread(hThread);
  beforeTime=timeGetTime();
  MessageBox(hWnd,"ゲームを再開しますか?","一時停止中",MB_OK);
  ResumeThread(hThread);
  playTime-=(timeGetTime()-beforeTime);
  break;

■プレイ時間の端数表示

実際に一時停止を実行してみると、
周期時間 sleep 未満の値だけプレイ時間が加算される事に疑問をもったかもしれません。
この端数は一時停止する前に経過した動作制御スレッドの待機時間です。
待機は一時停止する前に行われていたのですから、これもまたプレイ時間です。
この状況が発生する条件は「残りの待機時間より多く中断した」場合です。
再開と同時に待機が解除され、プレイ時間の更新、再描画が行われるんでしたね。

また、端数が表示されるもう一つのパターンとして、
メインスレッドからの介入により待機を解除した場合があります。
具体的に言えばプレイヤーが故意にピースを固定させようとした時です。
こちらは sleep 未満の時間しかたっていないのに待機を解除したからです。

■画面デザイン

これはもう、文章で説明するよりプログラムを示した方が分かり易いでしょう。

// 描画する
void Paint(HDC hdc)    // hdc:デバイスコンテキストのハンドル
{
    int x,y,ptx,pty;
    HBRUSH hBrush,hOldBrush;

    SelectObject(hdc,GetStockObject(NULL_PEN));
    for(y=0;y<FIELD_HEIGHT;y++){    // ゲームフィールドのブロック
        for(x=0;x<FIELD_WIDTH;x++){
            ptx=(x+1)*CELL_WIDTH;
            pty=(y+1)*CELL_HEIGHT;
            if(field[x][y]){
                hBrush=CreateSolidBrush(fColor[x][y]);
                hOldBrush=(HBRUSH)SelectObject(hdc,hBrush);
                Rectangle(hdc,ptx,pty,ptx+CELL_WIDTH,pty+CELL_HEIGHT);
                SelectObject(hdc,hOldBrush);
                DeleteObject(hBrush);
            }
        }
    }
    for(y=0;y<PIECE_HEIGHT;y++){            // 現在移動中のブロック
        if((location.y)+y < 0) continue;    // ゲームフィールドの枠より上は描かない
        for(x=0;x<PIECE_WIDTH;x++){
            ptx=((location.x)+x+1)*CELL_WIDTH;
            pty=((location.y)+y+1)*CELL_HEIGHT;
            if(piece[x][y]){
                hBrush=CreateSolidBrush(pColor[x][y]);
                hOldBrush=(HBRUSH)SelectObject(hdc,hBrush);
                Rectangle(hdc,ptx,pty,ptx+CELL_WIDTH,pty+CELL_HEIGHT);
                SelectObject(hdc,hOldBrush);
                DeleteObject(hBrush);
            }
        }
    }
    for(y=0;y<PIECE_HEIGHT;y++){    // 次のブロック
        for(x=0;x<PIECE_WIDTH;x++){
            ptx=(FIELD_WIDTH+2+x)*CELL_WIDTH;
            pty=(y+1)*CELL_HEIGHT;
            if(next[x][y]){
                hBrush=CreateSolidBrush(nColor[x][y]);
                hOldBrush=(HBRUSH)SelectObject(hdc,hBrush);
                Rectangle(hdc,ptx,pty,ptx+CELL_WIDTH,pty+CELL_HEIGHT);
                SelectObject(hdc,hOldBrush);
                DeleteObject(hBrush);
            }
        }
    }
    SelectObject(hdc,GetStockObject(WHITE_PEN));
    SelectObject(hdc,GetStockObject(NULL_BRUSH));
    Rectangle(hdc,CELL_WIDTH,CELL_HEIGHT,                    // ゲームフィールドの枠
        (FIELD_WIDTH+1)*CELL_WIDTH,(FIELD_HEIGHT+1)*CELL_HEIGHT);
    Rectangle(hdc,(FIELD_WIDTH+2)*CELL_WIDTH,CELL_HEIGHT,    // 次のブロックの枠
        (FIELD_WIDTH+2+PIECE_WIDTH)*CELL_WIDTH,(PIECE_HEIGHT+1)*CELL_HEIGHT);

    char buf[32];
    SetTextColor(hdc,RGB(255,255,255));
    SetBkMode(hdc,TRANSPARENT);
    // 獲得点数
    wsprintf(buf,"SCORE");
    TextOut(hdc,(FIELD_WIDTH+2)*CELL_WIDTH,(PIECE_HEIGHT+2)*CELL_HEIGHT,buf,(int)strlen(buf));
    wsprintf(buf,"%d",score);
    TextOut(hdc,(FIELD_WIDTH+2)*CELL_WIDTH,(PIECE_HEIGHT+3)*CELL_HEIGHT,buf,(int)strlen(buf));
    // プレイ時間
    wsprintf(buf,"PLAY TIME");
    TextOut(hdc,(FIELD_WIDTH+2)*CELL_WIDTH,(PIECE_HEIGHT+5)*CELL_HEIGHT,buf,(int)strlen(buf));
    wsprintf(buf,"%02d:%02d:%03d",(playTime/1000)/60,(playTime/1000)%60,playTime%1000);
    TextOut(hdc,(FIELD_WIDTH+2)*CELL_WIDTH,(PIECE_HEIGHT+6)*CELL_HEIGHT,buf,(int)strlen(buf));
}

■ウィンドウサイズを調節する

クライアント領域に描画する内容はピクセル単位で決まっています。
従って、ウィンドウサイズも希望のクライアント領域がちょうど収まるように調節しなければいけません。
必要なクライアント領域のサイズは
横=(FIELD_WIDTH+7)*CELL_WIDTH , 縦=(FIELD_HEIGHT+2)*CELL_HEIGHT
つまり、横=420 , 縦=520 ピクセルです。
背景画像もこのサイズに合わせて作る必要があります。
クライアント領域のサイズからウィンドウサイズを計算するには AdjustWindowRect() 関数を使います。

ちなみに、今回 AdjustWindowRect() 関数が計算してくれたウィンドウ座標は以下の通りでした。

rc.left = -3 , rc.top = -29
rc.right = 423 , rc.bottom = 523

また、ウィンドウのサイズ変更や最大化も禁止しましょう。
1 との排他的論理和をとればビット反転させることができます。
0 との排他的論理和ならばビットはそのままです。

int WINAPI WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,
    PSTR lpCmdLine,int nCmdShow)
{
    WNDCLASS wc;
    MSG msg;

    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = WindowProc;
    wc.cbClsExtra    = 0;
    wc.cbWndExtra    = 0;
    wc.hInstance     = hInstance;
    wc.hIcon         = LoadIcon(NULL,IDI_APPLICATION);
    wc.hCursor       = LoadCursor(NULL,IDC_ARROW);
    wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
    wc.lpszMenuName  = NULL;
    wc.lpszClassName = __FILE__;

    if(!RegisterClass(&wc)) return 0;

    // 指定したクライアント領域を確保するために必要なウィンドウ座標を計算する
    RECT rc={0,0,(FIELD_WIDTH+7)*CELL_WIDTH,(FIELD_HEIGHT+2)*CELL_HEIGHT};
    AdjustWindowRect(&rc,WS_OVERLAPPEDWINDOW ^ WS_THICKFRAME,FALSE);

    HWND hWnd=CreateWindow(
        __FILE__,"SPACE TETRIS",
        (WS_OVERLAPPEDWINDOW ^ WS_THICKFRAME ^ WS_MAXIMIZEBOX) | WS_VISIBLE,
        CW_USEDEFAULT,CW_USEDEFAULT,
        rc.right-rc.left,rc.bottom-rc.top,
        NULL,NULL,hInstance,NULL);
    if(hWnd==NULL) return 0;

    BOOL bRet;
    while((bRet=GetMessage(&msg,NULL,0,0))!=0){
        if(bRet==-1) break;
        DispatchMessage(&msg);
    }

    return (int)msg.wParam;
}

■全体を眺める

それぞれの動作が理解できたら、全体を眺めてみましょう。
全体の流れを把握できてこそ真に理解できたと言えるでしょう。

ソースファイル

■演出の課題

今回、ブロックの移動はマス単位となっています。
行を削除した後、“滑らかに”上のブロックが落ちてくる……という演出は諦めました。
あまり必要性も感じませんでしたしね。
しかし、それでは困るゲームもあるでしょう。
ならばマス座標に代わる何かを探すのか?
私が考えるに、マス座標はどんな落ち物ゲームでも使われているのではないでしょうか。
ただし、次のマスに移動するまでに
スレッドによって細かな移動制御と時間制御がなされているものと考えます。
私も現時点では具体的な解決法を見つけられません。
皆さんも考えてみて下さい。

■挨拶

長い解説に最後まで付き合って頂きありがとうございました!
そしてお疲れさまです。
でも、あなたの本当の勉強はこれから始まるんでしょうけど……ね(笑)。


前へ 4/4

戻る / ホーム