ゲームプログラミング/ブロック崩し
具体的な例:ブロックくずし
編集ここまでで2次元の描画と入力機器の扱いかたについて述べてきました。ここではこれらを使った具体的な例として、ブロックくずしをあげます。
ブロック崩しは有名なゲームですが、一応内容について説明します。このゲームでは、プレイヤーはバットと呼ばれる板状のものを操作します。バットは画面下方を左右に移動することができます。プレイヤーは画面上方に向けてバットからボールを発射します。ボールは画面の端で跳ね返り、やがて画面下方に戻ってきます。この時ボールが画面下方に向けてバットよりも下に行かないように、プレイヤーはバットでボールを打ち返します。画面上方にはボールを当てることで壊れるブロックが複数配置されており、プレイヤーは破壊できるブロックを全て破壊することを目的とします。
図
編集実際にこのことをプログラムとして書こうとするとおおよそ1,000行程度のCプログラムになるようです。ここでは既に完成している例として、gnome-breakoutを利用し、2Dゲームのプログラミングとして代表的な部分について解説していきます。ここでは特に、gnome-breakout-0.5.3を利用しました。ここからの解説ではこれらのコードを参照することが多いので、ゲームのソースを入手しておくとよいでしょう。一般的なソースの読み方については、OSS開発ツールなどを参照してください。
前提として、gnome-breakoutではゲームのライブラリとしてGNOMEを利用しています。GTK+はGnomeのGUIツールキットなので多くの描画はGTK+の関数を利用してなされます。GTK+の関数についてはGnomeのサイトかOSS開発ツール GUIツールキットを参照してください。
メインループ
編集このゲームはボールやバットの操作を行うため、定期的に画面の書き換えを行います。画面の書き換えは繰り返し実行されますが、この繰り返される部分をメインループと呼ぶ事があります[1]。メインループは一部のパズルゲームを除くあらゆるゲームで用いられる技術です。基本的にはメインループは、
while(state == RUNNING){
...;
}
のように無限ループを使って書かれます。RUNNINGは大抵の場合ゲームの状況を表すenum(列挙子)で、
typedef enum {
RUNNING, STOPPED
} state;
などと定義されます。今回のゲームに関しては、メインループはsrc/game.c内のiterate_gameで定義されています。ただし、ここで使っているソースは見やすいように編集してあります。
while (game->state == STATE_RUNNING) {
// 中略
iterate_bat(game);
iterate_balls(game);
iterate_powerups(game);
iterate_blocks(game);
// 中略
gui_update_game(game);
// 中略
gettimeofday(&end_tv, &tz);
diff_t = ((start_tv.tv_sec - end_tv.tv_sec) * USEC_PER_SEC)
+ (start_tv.tv_usec - end_tv.tv_usec)
+ USEC_PER_FRAME;
if (diff_t > 0)
usleep(diff_t);
}
STATE_RUNNINGは、src/breakout.hで定義されたenumです。この例はいくつかの重要な技法を含んでいるので、ここで紹介します。
物体ごとの処理
編集上の例ではwhileループ(メインループ)内でiterate_xxxで書かれるいくつかの関数が実行されていますが、この関数名は自己を説明しています。これらの関数はそれぞれbatやballなど対応するデータの移動や衝突判定などを行います。実はiterate_blocksではブロックが破壊される際のアニメーションを行っているのですが、ここでは詳しく述べません。いずれにせよメインループ内で実行される関数はゲーム内で対応するキャラクターなどを持っており、それらに操作をするための関数である場合がほとんどです。オブジェクト指向を使ってプログラムを書くなら、上の例は、
bat->iterate(game);
balls->iterate(game);
...
などとなるでしょう。この場合おそらくbat, ballなどのクラスはiterateをvirtual(C++の時)なメソッドとして持つクラスを継承します。
画面の書き換え
編集上の例は、gui_update_gameという関数が続いています。この関数はいくつかの処理をしますが、ここで注目する距離は、関数の最後の処理で
// 中略
gnome_canvas_update_now(gui->canvas);
return;
}
の部分です。gnome_canvas_update_nowはgnomeが提供する関数で、ゲームの描画を行う領域全体を書き換えます。上のiterate_xxx内でボールの移動などの処理は既に実行されているのですが、ここで変更したのはあくまでボールの位置を表す数値であり、それだけで画面の表示は変更されません。そのため、画面の変更をこの部分で一律に行っていることを示しています。
実際には例えば、iterate_ballの中で、
old_pos = ball->pos;
move_ball(ball);
update_rect(old_pos, ball_width, ball_height);
update_rect(ball->pos, ball_width, ball_height);
のように画面の書き換えを行うこともできます。ここで、update_rectは、
update_rect(pos, width, height);
で表され、posで表される場所から、 (width, height) で表される長方形で囲われる領域の描画を行う関数としました。実際には画面の描画は計算機に取って手間のかかる作業で、扱う領域が広いほど画面の再描画には多くの時間がかかります。そのため、上の例では2回の書き換えを行う必要があることを考えても、上の書き換えの方が高速で行われるでしょう。ただし、扱う画面が小さいなら書き換えは十分速く行われると期待されるので、どのように書き換えを行うかは状況によります。
fpsの管理
編集fpsはframe per secondの略で、1秒間に何コマ動くかという意味で、ゲームの時間制御の要となる概念です。計算機だけでなく、テレビもそうですが、これらの機器は1秒間に複数回の画面の書き換えを行います。例えば、日本のテレビではおおよそ30回/秒の書き換えを行います。ただし、テレビの書き換えはインターレース方式で行われるので、実質的に60回/秒の書き換えを行うことになります。詳しくはテレビを参照してください。ここで、画面の書き換えは例えば1/60秒で行われるとすると、これは非常に短い時間に見えます。しかし、例えば現在の計算機のクロック周波数が1GHZ程度とすると、計算機が1度の計算を行うのにかかる時間は、 秒です。これは画面の書き換えを行うのに必要な時間よりはるかに速いため、何も工夫をしなければ、計算機は画面の書き換えより速く物体の動作などの処理をしてしまいます。これは例えば、描画が行われないままボールが多くの距離を移動するなどの事につながるため、何らかの対策を講ずる必要があります。
ここで用いる方法は、メインループ1回分の処理を終えた後、次の画面の書き換えが行われるまで、プログラムの実行を止めておくという方法です。この方法には、プログラムの実行を一時的に止めるusleep関数を使います。
usleep(int microseconds);
usleep関数はmicrosecondsで表されるマイクロ秒だけ、プログラムの実行を止めます。実際にどの程度の間実行を止めるかは、プログラムが処理にかかった時間と、その結果を画面に送るのにかかった時間の和がどの程度だったかによります。上の例ではdiff_tが実際に実行を止める時間に対応します。gettimeofdayは、現在の時刻をマイクロ秒単位で取得する関数です。最後のgettimeofdayではend_tvの名の通り、処理が終わった時刻を取得しています。実際には上の例でiterate_xxxが行われる前に、gettimeofdayでstart_tvを取得している場面があり、これら2つを用いて処理にかかった時刻を得ることができます。画面の書き換えを1秒に実際に行う回数をfpsと呼びます。ここでは書かれていないのですが、このゲームは50fpsで動いているので、各々の書き換えの間に、20ミリ秒=20000マイクロ秒が経過します。処理にかかった時間をnマイクロ秒とすると、
diff_t = 20000 - n
で実行を止める時間が計算されます。上の例では何故か -n + 20000となっていますが意味は同じです。
ここからはballやbatなどそれぞれの処理を見ていきます。batの処理では、キーボードやマウスからの入力を受け取る方法について説明します。
バットの処理
編集このゲームではキーボードかマウスのどちらかを使ってバットの操作をします。ここではまず、キーボードやマウスの入力を読み取る方法について述べます。
バットの動作の処理はおおよそ次のようになっています。
if (key_right_is_pressed){
if (bat_x + bat_width + bat_move < GAME_WIDTH)
bat_x += bat_move;
}
else if (key_left_is_pressed){
if (bat_x - bat_move > 0)
bat_x -= bat_move;
}
ここで、それぞれの変数は
bat_x: バットのx座標 (0 < bat_x < GAME_WIDTH) GAME_WIDTH: 扱うウィンドウの幅 bat_move: 1度の処理でバットが動く距離
を表します。ここで、key_right_is_pressedとkey_left_is_pressedはキーの状態を表す変数です。ここでキーボードを扱う関数はこれらの変数の値を操作する必要があります。これらの変数はsrc/game.c内のkey_right(left)_pressedとkey_right(left)_releasedの4つで変更されています。それぞれの関数はおおよそ
void key_right_pressed(Game *game){
right_ispressed = TRUE;
}
のように対応する変数の中身を書き換えています。
- 注意
- 実際のコードでは、上でいうbat_moveの値に正負の値を取らせてバットの移動の処理を簡略化しています。また、右キーと左キーの両方が押された場合の処理も行っています。ここでは、簡単のためispressedの変数だけを扱います。
- C++と違い、C言語ではboolean型が存在しないため、TRUEやFALSEは普通のC言語プログラムでは扱われません。ここでのTRUEは、GLibで与えられているマクロであり、値は
- FALSE
- (0)
- TRUE
- (!FALSE)
- です。
上で紹介した4つの関数は、GTK+の関数にコールバック関数として渡されます。ただし、GTK+はkey_press_eventとkey_release_eventの2つの操作を与え、押されたキーごとに操作を与えるわけではありません。そのため、2つのイベントに対して1つずつのコールバックを与え、コールバック関数内でどのキーが押されたかを判断する必要があります。実際にコールバックとして扱われる関数は,src/gui-callbacks.c内のcb_keyupとcb_keydownです。これらはほとんど同じ処理を行うので、cb_keydownだけを紹介します。
gint cb_keydown (GtkWidget *widget, GdkEventKey *event, gpointer data) {
// 中略
if(event->keyval == game->flags->left_key) {
key_left_pressed(game);
} else if(event->keyval == game->flags->right_key) {
key_right_pressed(game);
}
// 中略
まずGTK+はキーボードからの入力をイベントとして扱います。イベントは機器からの入力などを一般化したもので、その多くはGTK+が依存しているライブラリから提供されます。例えばlinux上ではキーボードからの入力に関するイベントは対応するX Window Systemの処理から与えられ、Mac OS X上ではquartzの処理から与えられます。これらのイベントの詳細はGTK+の立場から見ることはできないので、詳細を見るには、対応するライブラリの説明を参照してください(Xプログラミングも参照)。GTK+からはあるウィジェットにフォーカスがある時に、何らかのキーが押されたときには、そのウィジェットに対して"key_press_event"が与えられます。このとき、このイベントに対応するコールバック関数には、イベントが発行されたウィジェットに加えて、GdkEventKey型の引数が与えられます。この型は押されたキーの種類などの情報を含んでいる構造体で、この引数を使って押されたキーを判別することができます。
上のcb_keydownも"key_press_event"のコールバックとして与えられる関数で、その引数は順に
gint cb_keydown (GtkWidet *, GdkEventKey *, void *)
となっています。ここで最初のGtkWidget *と、GdkEventKey *は上で述べたイベントが与えられたウィジェットと押されたキーの詳細を与える情報に対応します。最後のvoid * = gpointerは他に与えたい引数がある時に使うことができる変数ですが、ここではGame型のgameという引数を与えています。これはkey_left(right)_pressedの引数で、バットが動く方向などを記録しておく変数です。
ここまででキーが押されたときに実行させる関数を定義しました。次に実際にこの関数をキーが押された時の関数として登録する処理を見ます。この処理はsrc/gui.cのgui_init内で行われ、対応する部分は
// 中略
g_signal_connect(GTK_OBJECT (gui->app), "key_press_event",
GTK_SIGNAL_FUNC (cb_keydown), gui);
g_signal_connect(GTK_OBJECT (gui->app), "key_release_event",
GTK_SIGNAL_FUNC (cb_keyup), gui);
// 中略
です。g_signal_connectはGTK+で"イベント"に対するコールバックを登録する一般的な関数です。この関数の引数は、
g_signal_connect(GtkWidget *widget, char *event_name, GCallback cb_func, gpointer data);
で与えられ、それぞれイベントを扱わせたいウィジェット、ウィジェットが持つイベントのうちで対応するイベントを選ぶための文字列、コールバック関数、 コールバック関数に与えるデータとなっています。
- 注意
- 最初の引数は実際にはGtkWidget *である必要は無く、GObjectを継承したクラスのポインタならなんでも可能です。
ここで、GTK+のkey_press_eventとkey_release_eventが現れるタイミングについて説明します。key_press_eventとkey_release_eventはそれぞれキーが押され始めた瞬間と、キーが放された瞬間に与えられます。あるキーを押しつづけた場合key_press_eventが現れ続けるのではないため、キーが押しつづけられているのを知るためには、先ほど述べたkey_is_pressedなどの変数の値を設定しておき、key_release_eventが現れたときにこの値を書き換える必要があります。
ボールの動作
編集ボールはバットと違い、プレイヤーによる操作を受けないので、その意味ではボールの動作は単純です。しかし、このゲームではボールとバット、ボールとブロック等の間の衝突の判定を全てiterate_balls内で行っているため、iterate_ballsは割合複雑です。
iterate_ballsは、ball.cで定義されています。この処理はおおよそ
void iterate_balls(){
move_ball();
ball_block_collision();
ball_bat_collision();
ball_wall_collision()
}
となっています。ここではこれらの処理を順に見て行きます。一般に、物体を動作させその後に他の物体との衝突判定を行うという一連の手順はシューティングゲームやアクションゲームでは必ず現れる手順です。
ボールの移動
編集このゲームではボールの特性を表す構造体として、Ball (src/breakout.h) が定義されており、その内容は、およそ
typedef struct {
double x1, y1, x2, y2, speed, direction
} Ball;
で与えられます。
- 注意
- 実際にはこれ以外にもいくつかの要素が定義されていますが、ここではこれらだけを使います。
x1, y1, x2, y2はそれぞれボールが含まれる長方形の左下と右上の座標を指します。これはボールに限らず、物体の絵を保存する際には一般的に、長方形の領域が用いられることによります。speedとdirectionはそれぞれボールが動く速度とその方向を表します。x方向の速度をv_x, y方向の速度をv_yと表すなら、それぞれ
v_x = speed * cos(direction);
v_y = speed * sin(direction);
が成り立ちます。ここまでの結果を用いるとmove_ballの動作はほぼあきらかで、その操作はおよそ
void move_ball(Ball *ball){
double v_x, v_y;
v_x = speed * cos(direction);
v_y = speed * sin(direction);
ball->x1 += v_x;
ball->x2 += v_x;
ball->y1 += v_x;
ball->y2 += v_x;
}
となります。
一般的な衝突判定
編集ほとんどの場合物体と物体の衝突はそれぞれの物体を囲む長方形が重なることを用いて検出されます。このとき、物体を囲む長方形はできる限り物体の大きさと等しい大きさにしておくことが重要です。ここでは、ともにx1,y1,x2,y2を持つaとbという2つの物体があるとき、それらの衝突を判定する方法についてまとめます。実際に、このゲームでもボールとバットの衝突判定に、この手順が用いられています。
- 注意
- ボールとブロックの衝突判定はブロックが動かないことを利用した簡単化された手順を用いています。
まずx座標とy座標は互いに関係なく動かせるので、x座標だけについて考えます。この場合問題は2つの2次不等式 で表される領域xが存在するかどうかを確かめる問題に帰着します。2次不等式については高等学校数学Iを参照して下さい。
まず、a、bの各値が並ぶ順番が何通りあるかを計算します。a、bはそれぞれ2つのx座標を持っていますが、aの中での大小関係、bの中での大小関係は定まっており、常にx1 < x2です。そのため、例えばababのような並びを書けば、その順序は自動的にa.x1 < b.x1 < a.x2 < b.x2と定まります。この事を考えれば、a、bの値は単にa, a, b, bの4つの文字を並びかえる場合の数と等しい事が分かります。場合の数については高等学校数学Aを参照して下さい。
実際に取り得る値を書き並べると、
aabb, abab, abba, baab, baba, bbaa
の6つが挙げられ、全部で6つである事が分かります。この結果は計算法としては、互いに区別できない要素を含んだ順列の計算に対応しており、その場合には
に対応します。詳しくは高等学校数学Aを参照して下さい。
実際にはこれらの並びのうち4つが衝突が起こっている場合に対応します。具体的には4つの図が対応します。これらを一律に検出するには、例えば次のような試験が用いられます。
(b.x1 < a.x1 && a.x1< b.x2)||(b.x1 < a.x2 && a.x2 < b.x2)||(a.x1 < b.x1 && b.x2 < a.x2)
src/collision.cのcheck_collisionでは、これと同じ手順が書かれています。
上の操作をx座標とy座標について行えば、衝突の判定は完了します。
ボールと壁、ブロックの衝突判定
編集ボールや他の物体(ここではブロック)との衝突が確認されたら、次にボールがどちらの方向からぶつかったのかを確認します。この確認にはsrc/collision.cのfind_hit_side関数が使われます。この関数では、まずボールの速度と方向を使って、ボールが移動する前の位置まで戻します。ここでボールの位置とブロックの位置を調べる事で、ボールがどちらの方向からぶつかってきたのかを知る事が出来ます。
実際に物体が衝突したときどちらの方向へ反射するかはsrc/collision.cのrecalculate_ball_trajectoryで判定されます。仮にボールの速度をvxとvyで記録させるなら、右、左からの衝突では
vx *= -1;
上、下からの衝突では
vy *= -1;
で結果が得られることに注意が必要です。今回は速度と方向で記録している為、各方向毎の場合分けが必要です。
ブロック以外でゲームで利用するウィンドウの端にある壁と衝突する時にも上の関数は利用されます。壁との衝突判定は一般的な衝突判定より単純で
- ball.x<0 ではボールが左の壁に衝突した
- ball.x>GAME_WIDTH では、ボールが右の壁に衝突した
などとなります。
- 注意
- 実際のコード中では、実際には壁と壁の継ぎ目に衝突した場合の判定も行っています。
ボールとバットの衝突判定
編集ボールとバットの衝突は、src/collision.c内のball_bat_collisionで扱われます。この場合、ボールが反射する方向はバットとの位置関係で変化するようになっており、(おそらく)上級者はこれを用いてボールを狙った方向に送る事が出来ます。ここでは詳しくは扱いません。
最後に
編集ここまでで簡単なブロック崩しゲームに必要な手法について述べました。実際にはこのゲームではパワーアップの概念やレベル毎のブロックの配置の違いなど、説明していないいくつかの要素があります。それらも調べてみるとよいでしょう。
- ^ メインループを繰り返し文とも呼ぶ。