はじめに
本稿では、誰もが一度は遊んだことのある落ち物ゲームを模した「TETRA」というゲームを作ります。ルールを簡単に説明すると、次の通りです。
このゲームでは、落ちてくるブロックを積み重ねて、横一列を埋めることを目標とします。横一列が埋まるとブロックは消え、点数が入ります。上まで積み重ねてしまうと、ゲームオーバーです。
TETRAのルールは単純であり、落ち物ゲームの基礎を学ぶのにうってつけです。これをマスターすれば、他の落ち物ゲームを作ることもできます。また、ゲームプログラムで頻出のPeekMessage
関数を使ったメッセージループも解説します。
対象読者
ゲームプログラミング、特に落ち物ゲームに興味のある方。ただし、C言語とWin32APIの基礎を習得していること。
必要な環境
Visual C++ .NET 2002で開発し、Windows XP/98で動作確認しています。
データ構造
落ち物ゲームで重要なのはデータ構造です。図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種類とします。空白を設けているのは、回転を考慮してのことです。
// ピース作成 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
構造体のx
、y
メンバ変数が記録しています)。一つ下のセルにブロックがなければ移動できます。ただし、ピースの下端がフィールドの下端と等しくなっている時は移動できません。
// 下に移動 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); // 次のピースを作る }
しかし、このプログラムでは、ブロックの消去と浮いているブロックの落下を目視することはできません。以下では、これを改良して目視できるようにします。
- ピースを固定する
- ゲームオーバーしているか調べる
- 埋まった行がないか調べる
- ピースが下に移動できなかったら
- ゲームの進行を制御する関数
- アニメーションを制御する関数
- 埋まった行を消す
- 浮いたブロックを一行だけ落下
- ゲーム全体を制御する関数
// ピースをフィールドに固定 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; } } }
// ゲームオーバーしているかを調べる static bool CheckGameOver(void) { if(g_field.block[0][4] || g_field.block[0][5]) return true; return false; }
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; }
MoveDown
関数がfalse
を返した――下に移動できなかった時に呼び出されます。// ピースが下に移動できなかった時の処理 static BYTE PieceNext(void) { FixPiece(); if(CheckGameOver()) return 0; BYTE vanishLines=CheckLine(); if(vanishLines==0) { CopyPiece(); CreatePiece(&g_next); } return vanishLines; }
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(); }
progress
とwaitTime
で何もしない時間を設けて、処理を進めていることに注目してください。また、静的変数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; }
// 消滅フラグの立っている全ての行を消す 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; }
// 一行落下 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; }
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の例で考えてみましょう。図柄の意味と、各フレームの詳細は次の通りです。
図柄 | 状態 |
塗りつぶし | メッセージ処理 |
右下がり斜線 | ゲームの値を更新 |
右上がり斜線 | ゲームの画面を描画 |
空白 | スリープ |
- 第1フレーム
- 第2フレーム
- 第3フレーム
- 第4フレーム
- 第5フレーム
第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の値が関係しています。
練習または応用課題としては、次のようなものが挙げられます。
- ビットマップの代わりに色を使ってブロックを表現する
- スレッド化する
- セルをブロックの大きさより細かくする
参考資料
- 『Windowsゲームプログラミング』 赤坂玲音 著、ソフトバンクパブリッシング、2004年5月