C言語テトリスのソースコードを読む

[話者] C言語テトリスの記事 http://itouhiro.hatenablog.com/entry/20121112/tetrisソースコードを詳しく読んでみよう。

まず、

int board[12][25];

これがグローバル変数で、ウィンドウ内の画面を管理している。


ゲーム画面を見る限り、横10 x 縦20 の箱でできている。
f:id:itouhiro:20121112165121p:plain


しかしデータ上は 横12 x 縦25 で管理しているのだ。(C言語は1からではなく0から数えはじめる google:0オリジン ので、1~12ではなく0~11になる)
f:id:itouhiro:20121112170358p:plain
「Y軸を上に行くと+」という数学的座標系でこのソースは書かれている。




次はブロック定義の部分。

typedef struct _TAG_BLOCK {
    int rotate;
    POSITION p[3];
} BLOCK;

BLOCK block[8] = {
    {1, { {0, 0}, { 0,0}, { 0,0} } }, //null
    {2, { {0,-1}, { 0,1}, { 0,2} } }, //tetris
    {4, { {0,-1}, { 0,1}, { 1,1} } }, //L1
    {4, { {0,-1}, { 0,1}, {-1,1} } }, //L2
    {2, { {0,-1}, { 1,0}, { 1,1} } }, //key1
    {2, { {0,-1}, {-1,0}, {-1,1} } }, //key2
    {1, { {0, 1}, { 1,0}, { 1,1} } }, //square
    {4, { {0,-1}, { 1,0}, { 0,1} } }, //T
};

BLOCK型の配列変数には、降ってくるブロック7種が設定されている。


block[0]はカラの落下ブロックとなっている。



block[1]は

    {2, { {0,-1}, { 0,1}, { 0,2} } }, //tetris

の行の最初の数字 2 というのは回転パターンが2通りということ。
次の {0,-1}, { 0,1}, { 0,2} というのは以下の図形のことだ。
f:id:itouhiro:20121112211904p:plain
{0,0}はどのブロック種でも存在するので省略されているようだ。ちなみにブロックの回転は{0,0}が中心になる。



あとは同様に、
block[2]

    {4, { {0,-1}, { 0,1}, { 1,1} } }, //L1

f:id:itouhiro:20121112212546p:plain



block[3]

    {4, { {0,-1}, { 0,1}, {-1,1} } }, //L2

f:id:itouhiro:20121112212616p:plain



block[4]

    {2, { {0,-1}, { 1,0}, { 1,1} } }, //key1

f:id:itouhiro:20121112212643p:plain



block[5]

    {2, { {0,-1}, {-1,0}, {-1,1} } }, //key2

f:id:itouhiro:20121112212655p:plain



block[6]

    {1, { {0, 1}, { 1,0}, { 1,1} } }, //square

f:id:itouhiro:20121112212711p:plain



block[7]

    {4, { {0,-1}, { 1,0}, { 0,1} } }, //T

f:id:itouhiro:20121112212722p:plain



次のcurrentというグローバル変数は、落下中のブロックに関する情報を持つ。

typedef struct _TAG_STATUS {
    int x;
    int y;
    int type;
    int rotate;
} STATUS;

STATUS current;

x,yは位置で、typeというのは上記7種類のブロックのうちどれかということ。rotateとは回転パターン何番目かということ。




ソースの流れ

このソースはWinMain()から実行開始されるんだけど、そこではウィンドウの初期設定などをしているだけ。

int WinMain(HINSTANCE hInst, HINSTANCE hPrevInst, LPSTR cmdLine, int cmdShow) {
    ‥‥
}

実質的に動作の中心はWndProc()。参考: http://www.wisdomsoft.jp/426.html

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    switch (msg) {
    case WM_CREATE: {
        ‥‥
    }
    case WM_TIMER: {
        ‥‥
    }
    case WM_PAINT: {
        ‥‥
    }
    case WM_DESTROY: {
        ‥‥
    }
    default:
        ‥‥
    }
    return 0;
}


画面を最初に描写するときは WM_CREATEメッセージが発行されて、WndProc()内の WM_CREATEラベル内の処理を実行する。これは最初の一回しか実行されない初期設定などを実行。


その後は WM_TIMERメッセージが1000分の30秒(33ぶんの1秒だから‥‥だいたい30fps)ごとに発行されて、WndProc()内の WM_TIMERラベル内の処理を実行する。


WM_PAINTメッセージは、WM_TIMERラベル内のInvalidateRect()で発行されるので、WM_TIMERが実行されれば WM_PAINTラベル内も実行される。


ウィンドウを閉じるボタンを押したときに、 WM_DESTROYラベル内が実行される。


これが大きな流れ。この後はWndProc()内の各ラベルにかかれた処理を追ってみよう。


WM_CREATE メッセージの処理

        for (int x=0; x<12; x++) {
            for (int y=0; y<25; y++) {
                if (x==0 || x==11 || y==0) {
                    board[x][y] = 1;
                } else {
                    board[x][y] = 0;
                }
            }
        }

はステージの初期設定だ。
画面には表示されない部分だが「壁」とか「床」にあたる部分を 1 として、それ以外を 0 としている。
f:id:itouhiro:20121119165934p:plain

        current.x = 5;
        current.y = 21;

というのは最初に落下するブロックが画面上から出てくるときの画面上側の位置だ。

        current.type = random(7) + 1;
        current.rotate = random(4);

というのは落下ブロックの種類と向きはランダムに選ぶということだな。
つまり以下のように配置される。current.xとcurrent.yの位置に、各ブロックで{0,0}にあたる部分の配置になる。
f:id:itouhiro:20121119165949p:plain


そのあとputBlock()関数を呼んでる。

        putBlock(current);

putBlock()関数

putBlock()関数全体は、これ。

bool putBlock(STATUS s, bool action = false) {
    if (board[s.x][s.y] != 0) {
        return false;
    }
    if (action) {
        board[s.x][s.y] = s.type;
    }

    for (int i=0; i<3; i++) {
        int dx = block[s.type].p[i].x;
        int dy = block[s.type].p[i].y;
        int r = s.rotate % block[s.type].rotate;
        for (int j=0; j<r; j++) {
            int nx = dx, ny = dy;
            dx = ny; dy = -nx;
        }
        if (board[s.x + dx][s.y + dy] != 0) {
            return false;
        }
        if (action) {
            board[s.x + dx][s.y + dy] = s.type;
        }
    }
    if ( ! action) {
        putBlock(s, true);
    }
    return true;
}

まずは

    if (board[s.x][s.y] != 0) {
        return false;
    }

board[5][21]が0以外、つまり落下ブロックの{0,0}が「壁や床だったり、すでにブロック置いてある」だったときはブロックをput(置くこと)できない。そのときにfalseを返す。


次に

    if (action) {
        board[s.x][s.y] = s.type;
    }

変数actionがtrueならばboard[5][21](落下ブロックの{0,0})が7とかになる‥‥しかし現在はfalseなのでboard[5][21]は元の0のまま。この変数actionがtrueになっていると、「実際に」ブロックをそこに置く、というか落下ブロックがそこに移動する。actionがfalseのときは落下ブロックを置けるか確認してるだけで実際には置かない、ということらしい。


    for (int i=0; i<3; i++) {
        int dx = block[s.type].p[i].x;

forループは 0,1,2の3回するわけだが、その3ループで何するかというと、

block[7].p[0].x
block[7].p[1].x
block[7].p[2].x

を変数dxに代入して調べることになる。
このdxはdelta xの略で、{0,0}からの相対的な位置を示す。



ところで各落下ブロックはどれも4つのブロックを組み合わせて作られている。そのうち{0,0}についてはすでにif (board[s.x][s.y] != 0)で調べたので、残りの3つのブロックについて
「壁や床だったり、すでにブロック置いてある」かを調べるために、i=0,1,2で、3回ループを回すのである。

        int r = s.rotate % block[s.type].rotate;

現在の落下ブロックの回転パターンを、ブロックの回転パターン数の最大値でmod を取っている(整数で割ったときの余りの数を得る)。


これは実例で考えてみよう。s.rotateの初期値は

        current.rotate = random(4);

で決まり、rotateはint型だから、0,1,2,3の4種だ。あとはカーソルキーの↑を押すたびに

            n.rotate++;

で +1 される。上限はない様子。
いっぽうblock[s.type].rotateの初期値は1,2,4のどれかだ。


計算してみると‥‥
f:id:itouhiro:20121119001211p:plain


block[s.type].rotate が1のとき、r(= s.rotate % block[s.type].rotate)は常に0。

block[s.type].rotate が2のとき、s.rotateが偶数(0,2,4,..)ならrは0。1,2,5,..ならrは1。

block[s.type].rotate が4のとき、s.rotateが0か4の倍数なら、rは0になる。それ以外ならrは1,2,3のどれかの値を取ります。


ここでなぜmodするのかというと、ようするに「45°回転を25回やったところで、1回しか回転してないときと同じじゃないですか。だったら回転処理は1回ですませましょう」ということらしい。



次に

        for (int j=0; j<r; j++) {
            int nx = dx, ny = dy;
            dx = ny; dy = -nx;
        }

このforループで、変数nx,nyは一時変数。たぶんnext xの略のはず。
ここは落下ブロックの回転処理をしています。座標を置き換えるのですが‥‥具体的にみてみましょう。


まずはblock[s.type].rotateが1、つまりs.type=6 正方形ブロックの場合、rは常に0。つまりこのforループの継続条件j<0がfalseになるので、このforループは実行されない→回転しない。


block[s.type].rotateが2のもので、たとえばs.type=1 長い棒の場合、rはs.rotateが奇数なら1、それ以外は0になる。
s.rotateが0だったとすると、rが0なのでforループ動作せず。
s.rotateが1だったとすると、rが1なのでforループの中身が1回だけ実行される。つまり
i=0のときblock[7].p[0].x=0で、block[7].p[0].y=-1だから、
nx=0; ny=-1;
dx=-1; dy=0;
と、なる。
画像で見るとf:id:itouhiro:20121119160952p:plainのように移動しました。



そして、

        if (board[s.x + dx][s.y + dy] != 0) {
            return false;
        }

この移動後の位置にすでにブロック・壁・床があればreturn falseです。

        if (action) {
            board[s.x + dx][s.y + dy] = s.type;
        }

もしactionがtrueなら「実際に」ブロックが移動します。


以上をi=1,i=2のときも実行します。つまり画像で見ると
f:id:itouhiro:20121119162506p:plain
と、(0,0)以外は移動しました。





block[s.type].rotateが4のもので、たとえばs.type=7 凸ブロックの場合、rはs.rotateによって0,1,2のどれかになる。
s.rotateが0だったとすると、rが0なのでforループ動作せず。
s.rotateが1だったとすると、rが1なのでforループの中身が1回だけ実行される。つまり画像で見ると
f:id:itouhiro:20121119164025p:plain
と、なる。
s.rotateが2だったとすると、rが2なのでforループの中身が2回実行される。つまりさきほどの画像をさらに時計回りに90度回転させた状態になる。


それでint iのforループも終了した。
そのあと

    if ( ! action) {
        putBlock(s, true);
    }
    return true;
}


actionがfalseだった場合、再帰的にputBlock()を呼んで、今度はaction=trueにして、実際に移動させる。actionがfalseだった場合は、グローバル変数の値に何も変更を加えていなかったので、action=trueにすることで実際に変更を加えるというわけですね。ただし同じ確認を再帰先でもまた実行するわけなのでそれは無駄な感じもします。


結局、変数actionがなぜ必要だったかというと、落下ブロックの全部に対して、移動・回転しても障害物にぶつからないかをチェックして、すべてクリアだった場合だけ実際に回転とか移動とかさせるからですね。
もし変数actionなしだと、落下ブロックの一部分だけ回転したのに別の部分は障害物にひっかかって元の位置から動かない、というようなことが起きるので、それを避けたというわけ。



ここまでWndProc()のWM_CREATEの処理をみてきたが、残りの処理は画面表示関連のWin32APIなのでスルー。ここまでで、データ的にはこのようになった。
f:id:itouhiro:20121119170342p:plain


これでWndProc()内のWM_CREATEの場合はOK。次はWM_TIMERの場合を見ていこう。
WndProc()のWM_TIMER処理は次のようになってる。

    case WM_TIMER: {
        static int w = 0;
        if (w%2==0) {
            if (processInput()) {
                w = 0;
            }
        }
        if (w%5==0) {
            blockDown();
        }
        w++;

        InvalidateRect(hWnd, NULL, false);
        break;
    }

WM_TIMERは1秒に30回ほど呼ばれる。その2回に1回、processInput()を呼び出す。もしprocessInput()がtrueを返せばw=0になる。w=0ということはそのあとのblockDown()も実行することになる。


processInput()の中身を見てみる。

bool processInput() {
    bool ret = false;
    STATUS n = current;
    static int cursorLast = 0;

    if (GetAsyncKeyState(VK_LEFT)) {
        if (cursorLast != VK_LEFT) {
            n.x--;
            cursorLast = VK_LEFT;
        }
    } else if (GetAsyncKeyState(VK_RIGHT)) {
        if (cursorLast != VK_RIGHT) {
            n.x++;
            cursorLast = VK_RIGHT;
        }
    } else if (GetAsyncKeyState(VK_UP)) {
        if (cursorLast != VK_UP) {
            n.rotate++;
            cursorLast = VK_UP;
        }
    } else if (GetAsyncKeyState(VK_DOWN)) {
        n.y--;
        cursorLast = VK_DOWN;
        ret = true;
    } else {
        cursorLast = 0;
    }

    if (n.x != current.x || n.y != current.y || n.rotate != current.rotate) {
        deleteBlock(current);
        if (putBlock(n)) {
            current = n;
        } else {
            putBlock(current);
        }
    }
    return ret;
}


いったんグローバル変数currentをローカル変数nにコピーして、そのnに変更を加える。そしてnがcurrentから変更されていたら(カーソル入力していたら)、

  • まずdeleteBlock(current)で、カーソルで動かす前の落下ブロックをいったん画面から消す。これはよく考えれば、カーソル入力前の落下ブロックを障害物あつかいしないために必要な処理だ。
  • 次にputBlock(n)で、カーソル入力を反映させた落下ブロックを配置してみる。
  • もしret trueならブロックをカーソル入力を反映させた位置に配置してしまったので、ローカル変数nの値をグローバル変数currentにも一致させる。
  • もしret falseならブロックを動かしてないし、動かせないと確認できたので、変数n(カーソル入力情報)は捨てる。カーソル動かす前のcurrentの値でブロックを再配置する。
  • カーソル ↓ 押したときのみ、ret true。あとはret false。


deleteBlock()の中身を見てみる。

    board[s.x][s.y] = 0;
    for (int i=0; i<3; i++){
        int dx = block[s.type].p[i].x;
        int dy = block[s.type].p[i].y;
        int r = s.rotate % block[s.type].rotate;
        for (int j=0; j<r; j++) {
            int nx = dx, ny = dy;
            dx = ny; dy = -nx;
        }
        board[s.x + dx][s.y + dy] = 0;
    }
    return true;

putBlock()と同じような処理だが、 board[xx][yy]=0 と代入して、落下ブロックを画面から消去している点がちがう。




blockDown()は、30fpsの5回に1回、つまり1秒に約6回実行される。
このソースコードは?

void blockDown() {
    deleteBlock(current);
    current.y--;
    if ( ! putBlock(current)) {
        current.y++;
        putBlock(current);

        deleteLine();

        current.x = 5;
        current.y = 21;
        current.type = random(7) + 1;
        current.rotate = random(4);
        if ( ! putBlock(current)) {
            gameOver();
        }
    }
}
  • まずdeleteBlock(current)で、カーソルで動かす前の落下ブロックをいったん画面から消す。これはよく考えれば、カーソル入力前の落下ブロックを障害物あつかいしないために必要な処理だ。
  • グローバル変数のcurrent.yを低くする(落下ブロックを1段下に移動)。
  • putBlock(current)で移動させる。うまくいけばそれでよし→この関数抜ける。
  • うまくいかないとき、
    • current.yを戻す(落下ブロックを1段上に移動)。
    • 現在位置に再配置。
    • deleteLine()で「列揃え消し」判定する。
    • 新しい落下ブロックを上空に配置。
    • もし落下ブロックをおけないなら、gameOver()。

deleteLine()を見る。

void deleteLine() {
    for (int y=1; y<23; y++) {
        bool flag = true;
        for (int x=1; x<=10; x++) {
            if (board[x][y] == 0) {
                flag = false;
            }
        }

        if (flag) {
            for (int j=y; j<23; j++) {
                for (int i=1; i<=10; i++) {
                    board[i][j] = board[i][j+1];
                }
            }
            y--;
        }
    }
}

y軸の下から上へ、1行ずつブロックでうまったかを確認。
もしブロックでうまった行があれば(flag==true)、それより上の行のブロックをすべて一段下に移動。



gameOver()を見る。

void gameOver() {
    KillTimer(hMainWindow, 100);
    for (int x=1; x<=10; x++) {
        for (int y=1; y<=20; y++) {
            if (board[x][y] != 0) {
                board[x][y] = 1;
            }
        }
    }
    InvalidateRect(hMainWindow, NULL, false);
}
  • タイマー処理を停止する。→以後、画面が更新されなくなる。
  • すべての画面上のブロックを赤にする。
  • 画面を更新。


これでソースコード読了。