Chapter 18

壁との当たり判定を作ろう

eihigh
eihigh
2025.01.31に更新

前回はゲームの見た目を作りました。今回は、ゲームの肝である「壁との当たり判定」を実装します。

当たり判定

現実世界はそこに物があれば必ず当たり判定がありますが、ゲームの世界はものを描画しただけでは当たり判定は存在しないためスルーしてしまいます。ゲームの世界においては壁を貫通することはむしろ自然で、当たり判定はわざわざ後付けするものなのです。当たり判定を学んだら、世界のすべてのものに当たり判定をつける苦労に思いを馳せ、ゲーム開発者に優しくなってくれると嬉しいです。

今回はシンプルさを優先し、gopherくんの中心の点と、壁を囲む四角の当たり判定を行います。点と四角は、

  • 四角の左右の間に点のX座標が存在すること
  • かつ、四角の上下の間に点のY座標が存在すること

ことで当たり判定できます。図で見てみましょう。

上の図では白い点が当たっていないことを、黒い点が当たっていることを示します。

ただ、よく見ると点が四角に「当たっていない(白い点)」にも関わらずgopherの体は壁に当たっているように見える、という状況があります。ですがこれは意図したもので、敢えてプレイヤーの当たり判定を小さくとることでゲームとしての不快感を減らすようにしています。後半で詳しく解説するのでお見逃しなく。

これをプログラムに落とし込みます。「vがaとbの間にあること」は a < v && v < b のように二つの比較を &&(かつ)で繋いで表します。それがX座標とY座標についてあって、どちらも満たしている必要があるので、やはり && で繋ぎます。

if left < x && x < right && top < y && y < bottom { ... }

計算の優先度は && より < が上なので、カッコをつけて計算順序を明示するとこうなります。

if (left < x) && (x < right) && (top < y) && (y < bottom) { ... }

実際の実装は以下のようになります。cx, cy の "c" は、中心を意味する "center" の頭文字からとってみました。

 	// ...前略...
 	// すべての壁を左に移動
 	for i := range wallXs {
 		wallXs[i] -= 3
 	}
 
 	// すべての壁を描画
 	for i := range wallXs {
 		miniten.DrawImage("wall.png", wallXs[i], wallYs[i])
 	}
 
+	// 壁との当たり判定
+	// gopherくんの中心点と、壁の四角の当たり判定を行う
+	cx := 100 + 30/2    // 100=X座標、30=gopherくんの幅
+	cy := int(y) + 38/2 // 38=gopherくんの高さ
+	for i := range wallXs {
+		left := wallXs[i]
+		right := left + 100 // 100=壁の画像の幅
+		top := wallYs[i]
+		bottom := top + 360 // 360=壁の画像の高さ
+		if left < cx && cx < right && top < cy && cy < bottom {
+			miniten.Println("ぶつかっている")
+		}
+	}
 }

これで当たっているときに画面左上にメッセージが表示されればOKです!

天井をつける

ところで、これだけだと実は画面外の上に逃げれば当たり判定を回避できてしまいます。天井に頭をぶつけたらそれ以上上に行かないように修正しましょう。

 	// 結構な速度で画面の下に落ちていくので、360より下に行かないよう制限する
 	if y > 360 {
 		y = 360
 	}
+	if y < 0 {
+		y = 0
+	}

スコアを表示する

このゲームのスコアはどれだけ壁をくぐり抜けて生き延びたか、です。本当は壁をくぐり抜けたことを当たり判定してスコアを一つずつ加算すべきなのでしょうが、面倒なのでここではズルをして、生き延びた「時間」をそのままスコアとして表示してしまいましょう。最終的なプログラムは以下になります。

package main

import (
	"math/rand/v2"

	"github.com/eihigh/miniten"
)

var (
	y  = 0.0
	vy = 0.0 // Velocity of y(速度のy成分)の略

	frames = 0       // 経過フレーム数
	wallXs = []int{} // 壁のX座標
	wallYs = []int{} // 壁のY座標
)

func main() {
	miniten.Run(draw)
}

func draw() {
	miniten.DrawImage("sky.png", 0, 0)

	if miniten.IsClicked() {
		vy = -6 // ジャンプ
	}
	vy += 0.5 // 速度に加速度を足す(重力)
	y += vy   // 座標に速度を足す

	// 結構な速度で画面の下に落ちていくので、360より下に行かないよう制限する
	if y > 360 {
		y = 360
	}
	if y < 0 {
		y = 0
	}

	// x座標はちょっと画面端から離します
	miniten.DrawImage("gopher.png", 100, int(y))

	// 100フレームごとに壁を追加する
	frames += 1
	if frames%100 == 0 { // 100で割った余りが0なら
		// 穴の上側Y座標は0から199の範囲とする
		holeY := rand.N(200)
		// 穴の高さは100とする
		holeHeight := 100
		// 上の壁を追加
		wallXs = append(wallXs, 640)
		wallYs = append(wallYs, holeY-360)
		// 下の壁を追加
		wallXs = append(wallXs, 640)
		wallYs = append(wallYs, holeY+holeHeight)
	}

	// すべての壁を左に移動
	for i := range wallXs {
		wallXs[i] -= 3
	}

	// すべての壁を描画
	for i := range wallXs {
		miniten.DrawImage("wall.png", wallXs[i], wallYs[i])
	}

	// 壁との当たり判定
	// gopherくんの中心点と、壁の四角の当たり判定を行う
	cx := 100 + 30/2    // 100=X座標、30=gopherくんの幅
	cy := int(y) + 38/2 // 38=gopherくんの高さ
	for i := range wallXs {
		left := wallXs[i]
		right := left + 100 // 100=壁の画像の幅
		top := wallYs[i]
		bottom := top + 360 // 360=壁の画像の高さ
		if left < cx && cx < right && top < cy && cy < bottom {
			miniten.Println("ぶつかっている")
		}
	}

	// スコアを表示する
	miniten.Println("Score:", frames)
}

まとめ

ゲーム開発の中でも特に難関とされる当たり判定を今回乗り越えることができました。素晴らしい!もちろん今回の当たり判定は非常に初歩的なものですがそれでも当たり判定の世界への道を開く偉大な一歩です。誇っていきましょう。次回は、ゲームオーバーを含む画面遷移を実装して、ゲームの体裁を整えて完成させます。

詳しい解説

当たり判定と形状

今回扱った当たり判定は点と四角ですが、形状が異なれば異なるプログラムで当たり判定を書く必要があり、それぞれ難易度が大きく異なります。より詳しい解説は伝説の神サイト「マルペケつくろーどっとコム」を参照いただきたいのですが、ざっくり紹介すると、簡単なものとして円と点、円と円の当たり判定があります。また四角と四角も、点と四角の自然な拡張で判定できます。さらに意外なところとして、「カプセルと点または円」の判定が比較的簡単です。カプセルは細長いレーザー攻撃や、プレイヤーキャラにも使えたり、応用範囲が広いのでおすすめです。

なお、これまで「四角」と称してきたものは厳密には「縦も横も座標軸と平行な長方形」で、AABB(Axis-Aligned Bounding Box)と呼びます。AABBはX軸とY軸についてバラバラに交差判定して、その後 && で繋げばいいので非常に単純ですが、そうではない四角、例えば傾いた長方形の当たり判定はそうは行きません。結構難しいです。

他の形状、多角形や楕円は、自力で書くのはおすすめしないくらいには難しいです。後述する衝突応答も含めて物理演算パッケージで提供されているものを利用するのがベストでしょう。しかしだからと言って自力で当たり判定を書く意味がないわけではありません。複雑な当たり判定は、いくら優秀な物理演算プログラムであっても処理が重くなるのは避けられません。処理を軽くしたい箇所で経験に基づいて「ここはざっくりした当たり判定で十分だから、シンプルな形状の組み合わせで行こう」などと選択できるのは重要なエンジニアリングのスキルです。

高速に移動する物体の当たり判定

他にも「高速に移動する物体への対処」も頭の痛い問題です。今回は毎フレーム点と四角が重なっているかで判定していますが、もし1フレームで壁を通り過ぎるほど高速に移動している場合、「このフレームでの位置が重なっているか」ではうまく判定できません(貫通してしまいます)。そのような物体を扱うには移動経路(直線とは限らない!)を当たり判定で考慮する必要があります。どうやって考慮するかにも無数の方法があり、厳密に線が交差する座標を求める方法もありますし、少しずつ物体を動かしてみて(移動経路を分割して)判定する方法もあり、それぞれにメリット・デメリットがあります。一回は実装してみると勉強になると思いますが、無理は言えないですね。興味ある方はCCD(連続的衝突判定、Continuous Collision Detection)で検索してみて下さい。

当たり判定の削減

例えば敵が100匹いた時、プレイヤーと当たり判定するだけなら100回判定するのでそこまで問題になりませんが、もし敵同士で押し合ったりするなら100x100=10000回判定する必要があります。これは現代のコンピューターにとっても結構な負荷ですし、1000, 2000と増えると桁が爆発しいよいよ手に負えなくなります。しかし明らかに遠くにいる敵同士は当たるはずがないので、愚直に総当たりするのは無駄な場合があります。

そこで、総当たりを避けてざっくり近くにいる物体同士だけで当たり判定をするために、物体をある軸で整列するスイープ&プルーンや、小分けにした箱に物体を放り込んで、付近の箱だけで当たり判定を行う空間分割などの様々な手法があります。これもやはりマルペケつくろーどっとコムで解説されているので詳しくはそちらをどうぞ。

当たり判定の削減も例によってやり得とは言えず、例えば空間分割なら同じ物体のペアが無駄に複数回当たったと判定される問題があります。何回当たっても同じ結果になるように、当たり判定「以外」のゲームのプログラムを調整するか、それとも重複を弾く処理を入れるか...。エンジニアリングというのは常に選択の連続です。

当たり判定のサイズ

たまに当たり判定の形状を画像の見た目通りに、例えばgopherくんなら「目玉が少し出ていて、真ん中が少しくびれて、あと耳と足が少しだけ飛び出して...」のように正確に取ろうとする人がいますが、これはあまりお勧めしません。理由は、前述したように複雑な形状はプログラムが難しくなることと合わせて、ゲームデザイン的にも、見た目通りより気持ち小さい方が「かすったけどセーフだった!」と感じられて気持ちよくなれるからです。したがって、当たり判定の形状は、見た目より若干小さいシンプルな形状(AABBとか)にするのが常套手段となります。

今回はgopherくんの中心点を喰らい判定にしており、これはちょっと小さすぎる部類に入りますが、とはいえ一部の弾幕シューティングゲームなどでは大真面目に当たり判定が「点」だったりするので、迷ったら思い切り小さくしてしまっていいのでしょう。

衝突応答

はねるgopherくんゲームでは壁に当たった時点でゲームオーバーなのでほぼ関係ありませんが、ほとんどの場合当たり判定の後には複雑な衝突応答がついてきます。地形に当たった後、地形と重ならない位置までプレイヤーを押し戻すなどの処理ですね。これも難しい。というか当たり判定より難しいように思います。というのも、押し戻した先にどの地形とも重ならない安住の地が簡単に見つかればいいのですが、そうでないケースがあるからです。2Dゲームならまだマシで、3Dゲームだと、押し戻した結果角に挟まって超高速で振動したり、壁を貫いたり、挙げ句の果てに超高速で尻から飛んでいくなどの面白い困難な現象がよく起こります。

衝突応答を完璧にこなすプログラムは、筆者の知る限りだと地球上にまだ存在していないです。ゲームの内容に応じて、そこそこ正確でそこそこバグらない衝突応答を選択できるのが優秀なゲームプログラマーでしょう。

Goで使える物理演算パッケージ

Goで使える物理演算パッケージを3つ、簡単にご紹介します。

Chipmunk2D (github.com/jakecoffman/cp)

C言語で実装されたオリジナルのChipmunk2DをGoへ移植した、比較的シンプルな物理演算ライブラリです。Ebitengineの公式サイトにも簡単なサンプルが掲載されています。

https://ebitengine.org/ja/examples/chipmunk.html

https://github.com/jakecoffman/cp

Box2D (github.com/ByteArena/box2d)

こちらもC言語で実装されたオリジナルのBox2DをGoへ移植した比較的シンプルなライブラリです。Chipmunkとどちらを選ぶかは好みの範疇ですが、Ebitengineと組み合わせる人はChipmunkの方が多そうです。

こちらの記事は別のBox2D移植についての解説ですが、一応参考になるかと。
https://qiita.com/zenwerk/items/d15ee04335e1d1b8217b

https://github.com/ByteArena/box2d

resolv (github.com/SolarLune/resolv)

これは移植ではなくGoのために作られた物理演算ライブラリです。作者は他にもEbitengineで3D描画を実現するTetra3Dなどヤバいものを作っていることで知られるSolarLune氏です。個人的には比較的機能豊富に見えているのですが、使い込んだわけではないので有識者のコメント待ちです。作例はresolv自体が提供しているものを参照するのがベストです。

https://github.com/SolarLune/resolv