DXRuby Advent Calender 2013 12日目です。
11日目はfourxzさんのRuby使ったことない人がDXRubyでゲームを作りたいのでIDE3つインストールしましたでした。RubyにはWindows用のまともなIDEは無い物と思い込んでいたので、こんなに選択肢があるとは意外でした。これを機会に、試用してみたいと思います(デバッグトレースをCUIでやる気にはなれなくて……)。
さて12日目です。当初は別のエントリをアップする予定でしたが、DXRuby Advent Calenderのイベントを通じて、「ゲームプログラミング初心者は、意外とこういう所で躓くんじゃないか」と思い、2Dアクションゲームにおける衝突判定、特に、自キャラと障害物との衝突判定について、ざっくりまとめてみることにしました。
あくまで基本概念の説明であり、近代的なゲームプログラミングでは物理演算含めもっと厳密な判定処理を行っていると思うのですが、気軽に2Dアクションゲームを作るなら、これくらいの処理の方が実装が楽ではないかと思います。参考になれば幸いです。
本稿の目的
このエントリの目的は「スーパーマリオライクな2Dアクションゲームの、主人公キャラと障害物との衝突判定を実装する」ことにあります。あくまで主人公キャラと障害物、つまり「キャラと壁」の衝突判定を取り扱います。キャラ同士の衝突判定は取り扱いません(これについては後述します)。
基本方針1
障害物との衝突判定の基本は「移動先の座標に障害物が存在した場合、障害物に当たらない所まで座標を補正する」になります。この、「移動先座標に障害物が存在した場合」のことを「衝突する」と言い、その判断を「衝突判定」と呼びます。
このような実装をする際、以下のような課題が発生します。今回はこれらの実装方法について解説します。
- 移動先の座標に障害物が存在するかどうかをどのように判定するのか
- 衝突していた場合にどのように座標を補正するのか
- すりぬけをどう回避するか。
3の「すりぬけ」というのは、例えば32ドット×32ドットの床ブロックがあった時、上から落ちてくるキャラ(これも同じ大きさ)の落下座標増分が32ドット以上だった場合に、存在する筈のブロックが衝突判定上は考慮されず、そのブロックをすり抜けてしまう、というような現象を差します。
これについては、(現代的なゲームプログラミングであれば)移動方向の線分と床との衝突を計算するのかと思いますが(良く知らない)、今回は「1ブロックの大きさを超える量の移動増分を認めない」として、そもそもすりぬけは起きない物とします。
基本方針2
障害物との衝突判定の二つ目の基本は「画面上の見た目ではなく、もっと簡略化したデータ同士での衝突判定を行う」です。今回は、配列内に保存したマップデータを用いての衝突判定を行います。一種のスケーリングですね。
実装開始
では実装を始めましょう。いくつかのコードは部分的に掲載していますが、完全なソースコードは巻末にあります。外部のリソースデータを使っていないので、コピペして実行すれば動きます(Ruby及びDXRubyのインストールは必要です)。
マップの描画
まずはマップを描画します。配列に15×17のブロックからなるマップデータを設定し、それを1ブロック32×32ドットの四角形として描画します。縦横幅が中途半端な大きさなのは、単にそれが説明上楽だっただけで、他意はありません。
#マップデータ @map = [[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1], [1, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 1], [1, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1], [1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1], [1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1]]
ちなみに一番下の一行分は画面下部からはみ出していて表示されません。ここまでキャラが存在しても良いとしています。これは単に実装が楽だったからで、こうしなければならないという物ではないです。
マップデータの数値はそれぞれ以下の意味になります。
0 | なにもない(キャラが通過できる) |
1 | 障害物(キャラは通過出来ない) |
2 | ただの背景(キャラが通過できる) |
2は0と同じで、単に色が違うだけです。このように、配置パーツごとに属性を与えることで、複雑なマップを構成できます。
上端下端に穴が空いていますが、これは下の穴から落ちたら上から落ちてくるというつもりでいます。
それぞれのパーツはImageで生成します。もちろん、もっと凝った画像を作って、それに差し替えても構いません。
#配置パーツ @map_tile = [] @map_tile[0] = Image.new(32, 32, [0x00,0x99,0xff]) #背景1(空) @map_tile[1] = Image.new(32, 32, [0x66,0x33,0x00]) #障害物(ブロック) @map_tile[2] = Image.new(32, 32, [0xff,0xff,0xff]) #背景2(雲)
色情報が16進数なのは、単にWebセーフカラーのサイトを見ながら色を決めたからです。あとはマップ配列とパーツ配列をdraw_tileに読み込めば、一発でマップが描画されます。楽だ! DXRuby万歳!
#60フレだと説明しにくいので30フレにしておきます。 Window.fps = 30 #ゲームループ Window.loop do #マップの表示 Window.draw_tile(0,0,@map,@map_tile,0,0,17,15) end
キャラクターの表示
次はキャラクターを表示させます。配置パーツと同じ様にキャラのImageを作り、こっちはdrawで描画します。今回はImageをそのまま使っていますが、本来キャラはspriteで実装した方が後々楽でしょう(理由については後述します)。
#キャラ @char_tile = Image.new(32, 32, C_RED) ... #キャラの表示 Window.draw(x, y, @char_tile)
重力の設定と、床との衝突判定
重力の設定
さて、ここからが本番になります。まずキャラに重力を設定して、Window.loopの中で徐々に落ちていくようにします(重力とジャンプの計算式についてはこちらを参考にさせて頂きました>http://d.hatena.ne.jp/Gemma/20080517/1211010942)。
#Y軸移動増分の設定 y_move = (y - y_prev) + f #座標増分が1ブロックを超えないように補正 if y_move > 31 y_move = 31 end y_prev = y y += y_move f = 2 #f値を初期化し直す
yは現在のキャラY座標、y_prevは1フレ前のキャラのY座標です。前回からの移動距離に重力増分f(通常は2が入っています)を加算した数値を、今回の落下距離とします。落下距離の上限を31に設定しているのは、上で書いたすりぬけ防止のためです。最後にf値を初期化しなおしているのは、後述のロジックでf値が変更され得る為です。
参考として、y/y_prevともに0から初めて、このルーチンを回していく場合のyの変化を以下に示します(カッコの中はその回の増分値です)。徐々に落下増分が増えているのが分かると思います。
0(+0), 2(+2), 6(+4), 12(+6), 20(+8), 30(+10), 42(+12), 56(+14), 72(+16), 90(+18), 110(+20), 132(+22), 156(+24), 182(+26), 210(+28), 240(+30), 271(+31), 302(+31)...
床との衝突判定
落下していくキャラが床とぶつかったらそこで停まって貰う必要があります。実装の方針は以下になります。
- 重力での移動増分の後のキャラの位置をベースに判定を行う
- キャラの左下端、及び右下端の座標と重なっている配置パーツを調べる。
- 配置パーツのいずれかが移動不可パーツだった場合、座標を補正する
簡単に言えば「ひとまずキャラを移動させてしまい(これが1)、実際めり込んでいたら(これが2)、めり込まないように押し戻す(これが3)」ということになります。
キャラの座標を把握するためにこの画像を見て下さい。
32ドット×32ドットのキャラは、4隅の座標が上記のようになっています。これを元に、それぞれの座標を確認して行きます。
キャラの座標と、なにを比較して衝突判定をするのか。上でも書きましたが、画面の見た目ではなく、マップデータの配列と比較します。この処理は頻出するのでメソッド化しておきましょう。
#対応する配置パーツ番号を返す def collision_tile(x, y, arr) return arr[y/32][x/32] #マップ配列の仕様上、xとyが逆になっているのに注意 end
メソッドと言ってもたった一行ですが。キャラの座標を受け取って、その座標にある配置パーツの番号を返します。
yとxを32で割る事により、キャラの座標とマップ配列のスケールを合わせています(割り算では商だけが返ります)。これをスケーリングと言います(厳密な用法ではないかも)。このようにして、複雑なデータと単純なデータを比較できるようにします。
ちなみに、ここでは分かりやすく除算を使っていますが、プログラムとしては「y>>5」とビットシフトを使った方が速いし分かりやすいでしょう(コード可読性は落ちるかな……)。
また、マップ配列の構造上、"arr[y/32][x/32]"と、xとyが入れ替わっているのに注意して下さい。これはまあ、こういう物かなという気がします。
配置パーツの番号が入手できるようになったので、床との衝突判定を行います。
#床衝突判定 if collision_tile(x , y+31, @map) == 1 or collision_tile(x+31, y+31, @map) == 1 y = y/32*32 jump_ok = true #地面に接地しているのでジャンプを許可する else jump_ok = false #地面に接地していないので、ジャンプは許可しない end
キャラの右下端と左下端の座標(先の図を参照)を指定して配置パーツを取得し、そのどちらかが障害物(1)であれば、床に接地した物として、Y座標を補正します。"y/32*32"は同数を割って掛けてで意味がないと思われるかもしれませんが、これは32で割った時に余りが切り捨てられており、再び掛けた時にYが32の倍数(つまり、ぴったりブロックに乗っている状態)になるようにしています。
これも、プログラミング的には行儀が悪く、「(y>>5)<<5」とか、あるいはビット演算で下位4ビットをクリア(「y & ~31」かな? 間違ってたらすみません。NOTした値をANDする意味についてはこちらの記事も参照>http://d.hatena.ne.jp/mihael2/20050312)させた方がいいかもしれません(適切な方法を知りません)。
あるいは、衝突した障害物のY座標を正しく求めて、そこからキャラの高さを減算する方が、実装として正しいかもです。あ、最初からこうすべきだった気がしてきたな。反省……。
また、地面に接地している場合のみ、ジャンプを許可するフラグを立てています。これで、床との衝突判定は完了です。
左右移動と、壁との衝突判定
左右移動
次は左右キーでキャラ移動できるようにします。コードはこれだけ。
#左右移動 x += Input.x * 2
Input.xは右を押されていれば+1、左を押されていれば-1を返すので、それを2倍して、xに加算すれば左右移動が実現されます(このTIPSはアドベントカレンダ6日目のあおたくさんの記事から頂きました)。
本当は、徐々に移動速度が上がったり、慣性が効いたりした方が面白いと思います。実装方法を考えてみて下さい。
壁との衝突判定
移動先の障害物との衝突判定を行います。
#壁衝突判定(左側) if collision_tile(x, y , @map) == 1 or collision_tile(x, y+31, @map) == 1 x = x/32*32 + 32 end #壁衝突判定(右側) if collision_tile(x+31, y , @map) == 1 or collision_tile(x+31, y+31, @map) == 1 x = x/32*32 end
理屈は床の衝突判定と同じです。右方向と左方向でそれぞれ判定しています。左右それぞれの上下2隅と重なっている配置パーツを確認し、どちらか片方でも障害物にめり込んでいれば座標を補正します。左側にめり込んでいる場合、そのまま補正するとそのめり込んだ障害物と同座標になってしまうので、32ドット右側にずらしています。
ジャンプと、天井との衝突判定
ジャンプの実装
スペースキーを押したらジャンプできるようにしましょう。ジャンプ時のY座標更新ロジックですが、実はジャンプの実装は既に終わっています。先に書いた落下処理に、ジャンプが行われる最初のフレだけ、f値に-20を設定するのです(次フレからは元の+2に戻ります)。
すると、数値増分は以下のようになります。放物線を描いてまた戻ってくるのが分かります(また、元の座標(0)に戻った時に、強い移動量(+20)がかかっています)。
0(+0), -20(+-20), -38(+-18), -54(+-16), -68(+-14), -80(+-12), -90(+-10), -98(+-8), -104(+-6), -108(+-4), -110(+-2), -110(+0), -108(+2), -104(+4), -98(+6), -90(+8), -80(+10), -68(+12), -54(+14), -38(+16), -20(+18), 0(+20)
ジャンプ処理その物はこう書きます。jump_okフラグが立っている時(=地面に接地している時)しかジャンプを認めない事に注意して下さい。
#ジャンプ if Input.key_push?(K_SPACE) and jump_ok f = -20 end
天井との衝突判定
最後は天井との衝突判定です。これまでの応用なので、特記する事はありません。座標補正時に+32しているのは、左方向への衝突判定と同じ処理になります。
#天井衝突判定 if (collision_tile(x , y, @map) == 1 or collision_tile(x+31, y, @map) == 1) y = y/32*32 + 32 end
以上で、マップ内をキャラが動き回れるようになりました。全てのコードが入ったサンプルを一番下に置いておきます(初期化処理や、穴に落ちたら復帰する処理が入っています)。おつかれさまでした!
補講
2Dアクションゲームにおける簡易衝突判定入門は以上です。以下は、発展を含めた補講になります。
補講1:キャラ同士の衝突判定
敵キャラを出した場合にも、障害物の判定は上記のロジックを同じ様に適用します。
自キャラと敵キャラの衝突判定をする場合は、どちらもspriteで実装して、spriteの衝突判定ロジックを使うのが楽で早いです。
「敵キャラをspriteにするなら、障害物のパーツも全部spriteにすればいいんじゃない?」と思われる方もいるかもしれません。これはアプローチの差なので、そのやり方もアリだと思います。マップをスクロールさせた場合に、画面外に消えたspriteを消すのがちょっと面倒かもなので、それを上手く処理する必要があります。
また、敵キャラに限らず、「移動する床」などは無理にマップデータの配列では持たず、「当たってもダメージを受けない敵キャラ」として実装するのが良いと思います。ちなみに、移動する床の上にキャラが乗るようにした場合、その床の上で座標を合わせつつ通常と同じ移動を行わせるのは結構大変かもしれません。
補講2:配置パーツの拡張
今回は「通行可のパーツ」と「通行不可のパーツ」しかありませんでしたが、複雑なゲームを作る場合、これでは全く不足しています。例えば「下からぶつかると壊れる(スーマリのブロック)」「上から乗ると跳ね返る(同音符ブロック)」「乗ると強制的に横に移動する(同……あったっけ?)」などが考えられます。配置パーツの切り替えや、アニメーションなども考えたいところです。
また、変則的な物として「下からはすり抜けるが、上からは障害物扱いされる」というパーツも考えられます(すなわち、天井との衝突判定時には通行可と判定され、床との衝突判定時には通行不可と判定されるわけです)。パーツのバリエーションが増えると、IDごとの判定をハードコードするのは大変なので、上手いデータの持ち方が必要になるかも。
更に、斜面の配置パーツを作りたい場合、どう実装するかはなかなかに難しいでしょう。斜面に立たせる/歩かせる/滑らせるなどは、その斜面とキャラとのドット単位での判定が必要になります。ここまでくると、見た目との判定の方が速いのではないか? とも思えてきます。そうかもしれないし、そうでないかもしれません。
補講3:スクロールへの対応
今回は固定画面での実装となりましたが、どうせならマップをスクロールさせたいですよね。横方向へのスクロールは、マップデータが伸びるだけです。マップ全体の座標(ワールド座標と言います)と画面上の座標(同スクリーン座標)がずれることになりますが、DXRubyのオフセット座標機能を使えば大丈夫じゃないかな(良く知らない)。
横方向だけでなく、全方向スクロールさせたいとなった場合、またちょっと工夫が必要かもしれません(DXrubyのマップタイル機能だと、透過的に扱えるかも)。それ以前に、マップが大きくなったり増えたりすると、そのデータをどう管理するかの方が重要になってきます。
補講4:マップ配置パーツの処理
今回は障害物(パーツ番号1)しか判定しませんでしたが、実際にアクションゲームを作る場合、障害物のパーツだけで何種類も用意する事になるでしょう。その場合、どのように判定処理を行えば良いでしょうか。
1つのアプローチとして、通行可・不可を特定ビットで指定するというのがあります。例えば、配置パーツを256個(8bit)とする場合、最上位ビットが0なら通行可、1なら通行不可とすれば、以下のように判定できます(rubyでは、if式がfalseになるのは、演算結果がfalseかnilの場合のみである事に注意して下さい)
if parts & 0b10000000 != 0 #最上位ビットが立っている end
このように、各ビットに障害物の属性を与えて、それに応じて処理を分岐させる事ができます。ただし、上記のやり方はメモリや演算速度に余裕が無かった頃の手法なので、今は使わないかも。配置パーツIDごとに別途属性情報を用意して、そこで通行可/不可の設定を持っても構わないかと思います。
補講5:「スーパマリオブラザーズ」から挙動を学ぶ
文字通り前世紀のゲームではありますが、「スーパマリオブラザーズ」の挙動は当時の段階で2Dアクションゲームとしてほぼ完成の域に達していました(個人的には「スーパマリオブラザーズ3」が真の完成だと思っています)。プレイヤーをより気持ちよく遊ばせる為に、以下のような挙動がありました(書いていて思いついた分だけです)。
- 縦幅がキャラと同じ高さしか無い場所でもジャンプが出来た
- ジャンプ時に頭上にブロックがあった場合、位置関係によってはキャラがブロックを避けるようにX座標を調整した
- ブロックの端に寄り過ぎると、滑って落ちた(気がする。記憶違いかも)
- Bダッシュ中は、1マスの隙間なら落ちずに通り抜けられる。
他にもダッシュ中の急停止や、ジャンプ中移動の際に「向き」が考慮されているなど、学びたい点は無数にあります。これらをどう実装するか考えてみるのも面白いかもです。
補講6:コードの更なる効率化
今回は説明を楽するために冗長なコードになっています。例えば、キャラの4隅の配置パーツを取得すればいいのに、collision_tileは1フレ中に計8回呼ばれています。また、collision_tileは呼ばれる度に、xとyを32で割っています。これは効率が悪いのではないでしょうか。もっと短く、分かりやすいコードが書けるのではないでしょうか。
もし、これらの処理を一度に済ませるとした場合、ちょっと考えて欲しいのは、座標補正をどのタイミングで行うかという点です。今回はそこまでの考察はしません。練習問題にどうぞ。
おわりに
繰り返しになりますが、今回紹介したのは古典的な2Dアクションの衝突判定ロジックです。現代的なゲームプログラミングでは、もうマップ全部を3Dで組み、ポリゴン同士の衝突判定ロジックを使った方が楽かもしれません。あるいは、物理演算エンジンを使って床との接地を判定したほうがシンプルかもしれません。
けれど、スケーリングを初めとした多くのTIPSは普遍的な武器になりますし、古典的なロジックの方が軽いゲームでは実装しやすいだろうと、今回一通りの実装を行ってみました。現代的ゲームプログラミングに移行する際にも理解の助けになるかと思います。
また、今回、当初考えていたロジックが上手く機能せず、バグに悩んだ時間もあったのですが、それも含めて、コードを書き始めてから2時間程度でほぼ動作するサンプルを作ることができました。これはRuby+DXRubyの物作りの容易さから来ている物だと思っています。
それでは、Have a nice DXRuby game programing !
追伸
当初アップ予定だった「DXRubyの開発環境に(将来的に)欲しい物」については、三が日の新年一発目のネタにしようと思っています。
sample.rb
require "dxruby" #マップデータ @map = [[1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1], [1, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 1], [1, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1], [1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1], [1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1]] #配置パーツ @map_tile = [] @map_tile[0] = Image.new(32, 32, [0x00,0x99,0xff]) #背景1(空) @map_tile[1] = Image.new(32, 32, [0x66,0x33,0x00]) #障害物(ブロック) @map_tile[2] = Image.new(32, 32, [0xff,0xff,0xff]) #背景2(雲) #キャラ @char_tile = Image.new(32, 32, C_RED) #フレーム数設定 Window.fps = 30 #初期値設定 x = 32 y = y_prev = 32 f = 2 jump_ok = false #対応する配列を返す def collision_tile(x, y, arr) return arr[y/32][x/32] #マップ配列の仕様上、xとyが逆になっているのに注意 end #ゲームループ Window.loop do #Y軸移動増分の設定 y_move = (y - y_prev) + f #座標増分が1ブロックを超えないように補正 if y_move > 31 y_move = 31 end y_prev = y y += y_move f = 2 #f値を初期化し直す #穴に落ちたら座標を初期化 if y >= 480 x = 32 y = y_prev = 0 end #天井衝突判定 if (collision_tile(x , y, @map) == 1 or collision_tile(x+31, y, @map) == 1) y = y/32*32 + 32 end #床衝突判定 if collision_tile(x , y+31, @map) == 1 or collision_tile(x+31, y+31, @map) == 1 y = y/32*32 jump_ok = true #地面に接地しているのでジャンプを許可する else jump_ok = false #地面に接地していないので、ジャンプは許可しない end #左右移動 x += Input.x * 2 #壁衝突判定(左側) if collision_tile(x, y , @map) == 1 or collision_tile(x, y+31, @map) == 1 x = x/32*32 + 32 end #壁衝突判定(右側) if collision_tile(x+31, y , @map) == 1 or collision_tile(x+31, y+31, @map) == 1 x = x/32*32 end #ジャンプ if Input.key_push?(K_SPACE) and jump_ok f = -20 end #マップの表示 Window.draw_tile(0,0,@map,@map_tile,0,0,17,15) #キャラの表示 Window.draw(x, y, @char_tile) end