Shoeisha Technology Media

CodeZine(コードジン)

特集ページ一覧

落ち物ゲームの作り方 第1回:「TETRA」編

落ち物ゲームで学ぶゲームプログラミングの基礎

  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加
2005/11/14 12:00

本稿では、不朽の名作を摸した落ち物ゲーム「TETRA」を作ります。このゲームのルールは単純であり、落ち物ゲームの基礎を学ぶのにうってつけです。また、ゲームプログラムで頻出のPeekMessage関数を使ったメッセージループも解説します。

図1 サンプルプログラム「TETRA」の完成図
図1 サンプルプログラム「TETRA」の完成図

はじめに

 本稿では、誰もが一度は遊んだことのある落ち物ゲームを模した「TETRA」というゲームを作ります。ルールを簡単に説明すると、次の通りです。

 このゲームでは、落ちてくるブロックを積み重ねて、横一列を埋めることを目標とします。横一列が埋まるとブロックは消え、点数が入ります。上まで積み重ねてしまうと、ゲームオーバーです。

 TETRAのルールは単純であり、落ち物ゲームの基礎を学ぶのにうってつけです。これをマスターすれば、他の落ち物ゲームを作ることもできます。また、ゲームプログラムで頻出のPeekMessage関数を使ったメッセージループも解説します。

対象読者

 ゲームプログラミング、特に落ち物ゲームに興味のある方。ただし、C言語とWin32APIの基礎を習得していること。

必要な環境

 Visual C++ .NET 2002で開発し、Windows XP/98で動作確認しています。

データ構造

 落ち物ゲームで重要なのはデータ構造です。図2を見てください。落ちてくる「ブロック」の集合を「ピース」と呼ぶことにします。ピースは格子(ここでは「セル」と呼ぶ)を単位に移動します。「フィールド」とは、セルで構成される、ブロックを積み重ねる領域です。このようにセル単位で管理することにより、様々な判定を容易に行えます。また、セル単位ではなく、ピクセル単位でブロックを移動させるものもありますが、判定には同様のデータ構造を使います。

図2 データ構造と用語説明
図2 データ構造と用語説明

 ここでは、フィールドのセル数を(横、縦)=(10、18)としました。ピースのセル数は(縦、横)=(4、4)です。どちらも座標の原点は左上です。ピースのセル数については次節で述べます。また、セルのピクセル数は(縦、横)=(24、24)です。プログラムにすると、次のようになります。

// ピースのセル数
#define PW 4
#define PH 4

// フィールドのセル数
#define FW 10
#define FH 18

// セルのピクセル数
#define CW 24
#define CH 24

// ピース管理構造体
typedef struct tagPIECE
{
    bool block[PH][PW]; // true:ブロックあり / false:なし
    BYTE image;         // ブロックのビットマップ番号
    char x,y;           // 左上のセル座標
}PIECE;

// フィールド管理構造体
typedef struct tagFIELD
{
    bool block[FH][FW]; // true:ブロックあり / false:なし
    BYTE image[FH][FW]; // ブロックのビットマップ番号
    bool vanish[FH];    // 消滅フラグ
}FIELD;

 PIECE構造体、FIELD構造体のimageメンバ変数は、ブロックとして表示するビットマップの番号です。ビットマップを使わない場合は、色に相当します。FIELD構造体のvanishメンバ変数は、後で解説します。

 これらの構造体を使って、次のグローバル変数を宣言します。ピースには、現在プレイヤーが操作できるものの他に、次のピースもあらかじめ作っておき、プレイヤーに明示します。

static PIECE g_piece,g_next; // 現在と次のピース
static FIELD g_field;        // フィールド

ピースを作る

 ピースの種類は次に示す7種類とします。空白を設けているのは、回転を考慮してのことです。

図3 7種類のピース
図3 7種類のピース
// ピース作成
static void CreatePiece(PIECE *piece)
{
    for(BYTE y=0;y<PH;y++)
        for(BYTE x=0;x<PW;x++)
            piece->block[y][x]=false;
    piece->x= 3;
    piece->y=-3;

    piece->image=(BYTE)(rand()%7);
    switch(piece->image)
    {
    case 0:
        piece->block[1][1]=true; piece->block[1][2]=true;
        piece->block[2][1]=true; piece->block[2][2]=true;
        return;
    // 以下略
    }
}

ピースを移動させる

 ピースの移動方向は下、左、右です。それぞれの方向に移動できるか、を判定するためには、まず、ピースの下端、左端、右端を調べなければなりません。下端を調べるプログラムは次の通りです。左端、右端、そして上端を調べるプログラムは、サンプルファイルを参照してください。

#define ERR -1  // 関数のエラー戻り値

// ピースの下端を返す
static char GetPieceBottom(const PIECE *piece)
{
    for(char y=PH-1;y>=0;y--)
        for(char x=0;x<PW;x++)
            if(piece->block[y][x]) return y;
    return ERR;
}

 ピースの下端がわかったら、一つ下のセルを調べます(現在、フィールドのどこにいるかは、PIECE構造体のxyメンバ変数が記録しています)。一つ下のセルにブロックがなければ移動できます。ただし、ピースの下端がフィールドの下端と等しくなっている時は移動できません。

// 下に移動
static bool MoveDown(void)
{
    char bottom=GetPieceBottom(&g_piece);
    if(bottom==ERR) return false;

    if(g_piece.y + bottom == FH-1) return false;

    char fy,fx;
    for(BYTE y=0;y<=bottom;y++)
    {
        fy=g_piece.y + y;
        if(fy+1<0) continue;
        for(BYTE x=0;x<PW;x++)
        {
            fx=g_piece.x + x;
            if(g_piece.block[y][x] && g_field.block[fy+1][fx])
                return false;
        }
    }
    g_piece.y++;
    return true;
}

ピースを回転させる

 回転は、左または右に90度ずつ行います。回転可否の判定アルゴリズムを簡単に解説すると、回転したピースを仮に作って、フィールドと比較する、です。次のプログラムで回転したピースを作り、TurnPiece関数で判定します。

// 左回転
static bool TurnLeft(void)
{
    PIECE turn;

    // 回転したピースを作る
    for(BYTE y=0;y<PH;y++)
        for(BYTE x=0;x<PW;x++)
            turn.block[PW-1-x][y]=g_piece.block[y][x];
    turn.x=g_piece.x;
    turn.y=g_piece.y;

    return TurnPiece(&turn);
}

 TurnPiece関数の主な役割は、回転したピースとフィールドを比較して、回転の可否を調べることですが、ここでは、もう一つの機能を実装しています。それは、回転によってフィールドからはみ出してしまった場合に、フィールド内部に補正する機能です。補正した後に回転の可否を調べて、可能なら、回転したピースを現在のピースにコピーします。

// ピース回転
static bool TurnPiece(PIECE *turn)
{
    char top=GetPieceTop(turn);
    char bottom=GetPieceBottom(turn);
    char left=GetPieceLeft(turn);
    char right=GetPieceRight(turn);
    if(top==ERR || bottom==ERR || left==ERR || right==ERR) return false;

    if(turn->y+bottom >= FH) turn->y=FH-1-bottom;    // 下端はみ出し補正

    if(turn->x+left < 0) turn->x=-left;              // 左端はみ出し補正
    else if(turn->x+right >= FW) turn->x=FW-1-right; // 右端はみ出し補正

    BYTE x,y;
    char fx,fy;

    // 回転可能か調べる
    for(y=top;y<=bottom;y++)
    {
        fy=turn->y + y;
        if(fy<0) continue;  // 上端はみ出しは考慮しない

        for(x=left;x<=right;x++)
        {
            fx=turn->x + x;
            if(turn->block[y][x] && g_field.block[fy][fx]) return false;
        }
    }

    // 回転したピースを現在のピースにコピー
    for(y=0;y<PH;y++)
        for(x=0;x<PW;x++)
            g_piece.block[y][x]=turn->block[y][x];
    g_piece.x=turn->x;
    g_piece.y=turn->y;

    return true;
}

ピースの固定に続く一連の処理

 下に移動しようとして、できなかった時に、ピースはフィールドに固定されます。その時の動作を簡略化してプログラムにすると、次のようになります。

static void Sample(void)
{
    if(MoveDown()) return;      // 下に移動

    FixPiece();                 // ピースを固定する
    if(CheckGameOver()) return; // ゲームオーバーしているか調べる

    // 埋まった行を消去する 戻り値は消去した行数
    BYTE lines=DeleteLines();
    // 消去した最も下の行から詰める
    while(lines--) ShiftLine();

    CopyPiece();                // 次のピースを現在のピースにコピー
    CreatePiece(&g_next);       // 次のピースを作る
}

 しかし、このプログラムでは、ブロックの消去と浮いているブロックの落下を目視することはできません。以下では、これを改良して目視できるようにします。

  1. ピースを固定する
  2. 全体の制御は後で述べます。ピースを固定する処理で注意すべきは、現在のピースを消すことです。ピースを固定した後には、埋まった行を消して、浮いたブロックを落下させる、というアニメーションが挿入されます。その時、消したはずの場所に、現在のピースが居座り続けては困るのです。また、フィールドの上端よりはみ出したブロックは消します。
    // ピースをフィールドに固定
    static void FixPiece(void)
    {
        char fy,fx;
        for(BYTE y=0;y<PH;y++)
        {
            fy=g_piece.y + y;
            for(BYTE x=0;x<PW;x++)
            {
                if(g_piece.block[y][x]==false) continue;
                g_piece.block[y][x]=false; // ピースを消す
    
                if(fy<0) continue; // 上端よりはみ出したブロックは消す
    
                fx=g_piece.x + x;
                g_field.block[fy][fx]=true;
                g_field.image[fy][fx]=g_piece.image;
            }
        }
    }
    
  3. ゲームオーバーしているか調べる
  4. ゲームオーバーの条件は次の通りです。
    // ゲームオーバーしているかを調べる
    static bool CheckGameOver(void)
    {
        if(g_field.block[0][4] || g_field.block[0][5]) return true;
        return false;
    }
    
  5. 埋まった行がないか調べる
  6. 埋まった行が見つかった場合は、その行に消滅フラグを立てます。消滅フラグはFIELD構造体のvanishメンバ変数に記録します。消滅フラグは即時の消去と落下を要請するものではなく、消去して、落下するまで立ち続けます。また、これから消すことを明示するために、描画処理で消去フラグを見て、描画するビットマップを変更します。描画処理はサンプルファイルを参照してください。
    // 全て埋まった行に消滅フラグを立てる
    static BYTE CheckLine(void)
    {
        BYTE lines=0;      // 全て埋まった行数
        BYTE blocks;
    
        for(BYTE y=0;y<FH;y++)
        {
            blocks=0;
            for(BYTE x=0;x<FW;x++)
                blocks+=g_field.block[y][x] ? 1:0;
            if(blocks!=FW) continue;
    
            g_field.vanish[y]=true;
            lines++;
        }
        return lines;
    }
    
  7. ピースが下に移動できなかったら
  8. 1.~3.を制御するプログラムは次の通りです。この関数はMoveDown関数がfalseを返した――下に移動できなかった時に呼び出されます。
    // ピースが下に移動できなかった時の処理
    static BYTE PieceNext(void)
    {
        FixPiece();
        if(CheckGameOver()) return 0;
    
        BYTE vanishLines=CheckLine();
        if(vanishLines==0)
        {
            CopyPiece();
            CreatePiece(&g_next);
        }
        return vanishLines;
    }
    
  9. ゲームの進行を制御する関数
  10. ゲームの進行を制御する関数Nextは、ほぼ同じ時間間隔――フレーム周期――で呼び出されます。この時間を加算していき、ある時間以上になったら、ピースを下に移動させます。続く処理は、前述の通りです。Next関数は、処理の最初のほうで、アニメーションを必要に応じて実行する関数AnimNextを呼び出します。戻り値は、アニメーション中かどうか、です。
    #define MAX_FPS 60                           // 最大フレームレート
    const DWORD SPF=(DWORD)(1000.0/MAX_FPS+0.5); // フレーム周期[ms]
    static DWORD g_waitTime=1000; // 落下するまでの待機時間[ms]
    
    // 進行制御
    static void Next(void)
    {
        // 省略
    
        if(AnimNext()) return;   // アニメーション中は処理を進めない
    
        // 省略
    
        static DWORD progress=0;
        progress+=SPF;
        if(progress<g_waitTime) return;
        progress=0;
    
        if(MoveDown()==false) PieceNext();
    }
    
  11. アニメーションを制御する関数
  12. ゴチャゴチャしていますが、静的変数progresswaitTimeで何もしない時間を設けて、処理を進めていることに注目してください。また、静的変数phaseで、消去処理とシフト処理とを分岐させていること、そして、戻り値にも注目してください。DeleteLines関数、ShiftLine関数は次節から述べます。
    static DWORD g_score=0;   // 得点
    
    // アニメーション制御
    static bool AnimNext(void)
    {
        // 消滅フラグの立っている行があるか調べる
        if(IsVanish()==false) return false;
    
        static DWORD progress=0,waitTime=1000;
        progress+=SPF;
        if(progress<waitTime) return true;
        progress=0;
    
        static BYTE phase=0;
        if(phase==0)   // 全て埋まった行を消す
        {
            BYTE lines=DeleteLines();
            g_score+=1000*(DWORD)pow(2,lines-1);
            waitTime=500;
            phase=1;
            return true;
        }
        // 一行ずつシフト
        ShiftLine();
        waitTime-=100;
        if(IsVanish()) return true;   // まだ浮いている行がある
    
        CopyPiece();
        CreatePiece(&g_next);
    
        waitTime=1000;
        phase=0;
        return false;
    }
    
    IsVanishは、消去フラグの立っている行が一つでもあるか、を返す関数です。
    // 消滅フラグの立っている行の有無を返す
    static bool IsVanish(void)
    {
        for(BYTE y=0;y<FH;y++)
            if(g_field.vanish[y]) return true;
        return false;
    }
    
  13. 埋まった行を消す
  14. 埋まった行は、消滅フラグを見ればわかります。消滅フラグは、まだ使うので消しません。
    // 消滅フラグの立っている全ての行を消す
    static BYTE DeleteLines(void)
    {
        BYTE lines=0;      // 消した行数
        for(BYTE y=0;y<FH;y++)
        {
            if(g_field.vanish[y]==false) continue;
    
            for(BYTE x=0;x<FW;x++)
                g_field.block[y][x]=false;
            lines++;
        }
        return lines;
    }
    
  15. 浮いたブロックを一行だけ落下
  16. 消去した行を詰めるために、それより上の行を一つ下に詰めます。そして、詰めた行の消滅フラグを消します。
    // 一行落下
    static bool ShiftLine(void)
    {
        char y;
        for(y=FH-1;y>=0;y--)
        {
            if(g_field.vanish[y])
            {
                // 消滅フラグが立っている最下行
                g_field.vanish[y]=false;
                break;
            }
        }
        if(y<0) return false;            // 消滅フラグなし
    
        // 消滅フラグが立っている最下行から一行落下
        for(;y>=0;y--)
        {
            for(BYTE x=0;x<FW;x++)
            {
                if(y-1>=0)
                {
                    g_field.block[y][x]=g_field.block[y-1][x];
                    g_field.image[y][x]=g_field.image[y-1][x];
                }
                else g_field.block[y][x]=false; // フィールド上端
            }
            if(y-1>=0) g_field.vanish[y]=g_field.vanish[y-1];
            else g_field.vanish[y]=false;       // フィールド上端
        }
        return true;
    }
    
  17. ゲーム全体を制御する関数
  18. DrawNext関数は、メッセージループから呼び出され、進行(つまり値の更新)と、描画とを制御します。NULLを渡すと値の更新、デバイスコンテキストハンドルを渡すと描画を行います。
    // 進行制御と描画
    void DrawNext(HDC hdc)
    {
        if(hdc==NULL)      // 値の更新
        {
            Next();
        }
        else      // 描画
        {
            DrawBlock(hdc);
            DrawInfo(hdc);
        }
    }
    

ゲームのメッセージループ

 ゲームのメッセージループには、PeekMessage関数を使います。ゲームはイベント駆動ではないので、GetMessage関数との親和性が良くないからです。PeekMessage関数の戻り値は、メッセージの有無です。メッセージが有る時は、メッセージを処理し、無い時に、ゲームの処理をします。

int WINAPI WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,
                   LPSTR lpCmdLine,int nCmdShow)
{
    // 省略

    timeBeginPeriod(1);       // 最小分解能を1[ms]に設定する
    while(TRUE)
    {
        if(PeekMessage(&msg,NULL,0,0,PM_REMOVE))
        {
            if(msg.message==WM_QUIT) break;
            DispatchMessage(&msg);
        }
        else IdleProc();
    }
    timeEndPeriod(1);

    // 省略
}

メッセージが無い時の処理

 IdleProc関数は、不定期に実行されるので、1秒間に実行する回数(フレームレート)を調節しなければなりません。図4を見てください。上は理想のフレーム間隔で、下は実際のフレーム間隔の例です。数字はフレーム番号です。IdreProc関数は、現在のフレーム番号によって、経過しなければならない理想の時間を計算します。そして、実際の経過時間と比較し、処理を分岐させます。

図4 理想と実際のフレーム間隔
図4 理想と実際のフレーム間隔

 図4の例で考えてみましょう。図柄の意味と、各フレームの詳細は次の通りです。

図柄状態
塗りつぶしメッセージ処理
右下がり斜線ゲームの値を更新
右上がり斜線ゲームの画面を描画
空白スリープ
  • 第1フレーム
  • (a)メッセージ処理 (b)値の更新 (c)時間に余裕があるので、描画 (d)時間に余裕があるので、スリープ
  • 第2フレーム
  • (a)メッセージ処理 (b)値の更新 (c)時間に余裕があるので、描画
  • 第3フレーム
  • (a)メッセージ処理 (b)値の更新
  • 第4フレーム
  • (a)メッセージ処理 (b)値の更新
  • 第5フレーム
  • (a)値の更新 (b)時間に余裕があるので、描画 (c)時間に余裕があるので、スリープ

 第4フレームは、メッセージ処理だけで遅れが発生していますが、値の更新は必ず行います。第5フレームでメッセージ処理をしていないのは、メッセージが無かっただけのことです。

 プログラムにすると、次のようになります。ウィンドウが非アクティブの時は、何もしないようにしました。ここで、一瞬スリープしてから返すことに注意してください。スリープせずに返すと、フル回転することになります。

static bool g_isActive=true;   // アクティブ状態

// ゲーム全体を制御する
static void IdleProc(void)
{
    if(g_isActive==false){ Sleep(1); return; }   // 非アクティブ

    static DWORD beforeTime=timeGetTime();
    static DWORD frameCount=0,drawCount=0;

    DWORD nowTime=timeGetTime();
    DWORD progress=nowTime-beforeTime;

    frameCount++;
    DWORD time=(DWORD)(frameCount*1000.0/MAX_FPS+0.5);   // 理想の時間

    DrawNext(NULL);   // 値の更新

    if(progress<time)
    {
        nowTime=timeGetTime();
        progress=nowTime-beforeTime;

        if(progress<time)          // まだ時間に余裕がある
        {
            const RECT rc={0,0,WIDTH,HEIGHT};
            FillRect(g_hBackDC,&rc,(HBRUSH)GetStockObject(BLACK_BRUSH));

            DrawNext(g_hBackDC);   // 描画
            drawCount++;

            nowTime=timeGetTime();
            progress=nowTime-beforeTime;

            if(progress<time)      // まだ時間に余裕がある
            {
                Sleep(time-progress);
                nowTime+=(time-progress);
                progress=time;
            }
        }
    }

    if(progress>=1000)
    {
        beforeTime=nowTime;
        frameCount=0; drawCount=0;
    }

    InvalidateRect(g_hWnd,NULL,FALSE);   // 再描画
}

まとめ

 落ち物ゲームの基礎から一歩先までを解説しました。少し煩雑になってしまいましたが、実用レベルの作品になったと思います。

 解説しなかった内容としては、一定時間が経過する度に、落下するまでの待機時間を短くしていく機能などがあります。ゲーム画面に表示しているLEVELの値が関係しています。

 練習または応用課題としては、次のようなものが挙げられます。

  1. ビットマップの代わりに色を使ってブロックを表現する
  2. スレッド化する
  3. セルをブロックの大きさより細かくする

参考資料



  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加

著者プロフィール

バックナンバー

連載:ゲームプログラミング入門
All contents copyright © 2005-2020 Shoeisha Co., Ltd. All rights reserved. ver.1.5