【Python/tkinter】横スクロールアクションゲームを作る(より詳細な当たり判定)

横スクロールアクションゲームを作る(より詳細な当たり判定)の解説ページアイキャッチ

このページでは、Python で tkinter を利用した簡単な「横スクロールアクションゲーム」の作り方を解説していきます。

このページは「横スクロールアクションゲームの作り方の解説」の8ページ目となります。

7ページ目は下記ページとなり、7ページ目では主に敵キャラクターの作成の解説を行なっています。

横スクロールアクションゲームを作る(敵キャラクターの作成)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(敵キャラクターの作成)

また、このページでは、上記ページの このページで作成したスクリプト で紹介しているスクリプトを変更していきながらゲームを作成していきます。

ですので、事前に上記のページを読んでおくことをオススメします。

さらに、この「横スクロールアクションゲームの作り方の解説」の8ページ目では、下記の6ページで目で作成した大雑把な当たり判定を発展させることで、”より詳細な当たり判定” を実現していきます。

横スクロールアクションゲームを作る(ゴールの作成)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(ゴールの作成)

この大雑把な当たり判定について理解していただいていることを前提に解説を進めさせていただきますので、6ページ目の解説も事前に読んでいただくことをオススメします。

より詳細な当たり判定とは

このページで実現する “より詳細な当たり判定” とは、要は画像の透明度を考慮した当たり判定になります。

下記ページで当たり判定を導入しましたが、キャラクター同士が “当たっている” ことを、キャラクターが描画されている矩形同士が重なったかどうかで判断するようにしていました。

横スクロールアクションゲームを作る(ゴールの作成)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(ゴールの作成)

そのため現状では、下の図のように各キャラクターの画像の透明部分が重なっただけでも “当たっている” と判定されるようになってしまっています。

間違って当たっていると判定される様子

このページでは、各キャラクターが描画されている矩形同士が重なったかどうかだけでなく、各キャラクターの画像の不透明部分が重なっているかどうかまで判断を行うことで、より詳細に当たり判定を行うようにしていきたいと思います。

より詳細な当たり判定の実現方法

これは下記ページで詳しく解説しているのですが、画像によってはアルファチャンネルを画素ごとに設定可能であり、このアルファチャンネルの設定によりその画素を透明にすることが可能です。

Pythonでアルファチャンネルのみを取得する方法の解説ページアイキャッチ【Python】アルファチャンネルのみを画像から取得する方法(PIL・NumPy・OpenCV)

例えば、このページでゴールとして使用している下図の画像においては、

ゴール用の画像

転載元:いらすとや

下の図の黒色の画素が全て、アルファチャンネルによって透明で表示されるように設定されています。逆に白色の画素はすべて、アルファチャンネルによって不透明で表示されるように設定されています。

アルファチャンネルの設定値を表した図

このアルファチャンネルを利用すれば、2つのキャラクターが描画されている矩形が重なっているだけで “当たっている” と判断するような大雑把な当たり判定ではなく、2つのキャラクターの画像の不透明な画素同士が重なっているかどうかを判断することで、より詳細な当たり判定を行うことが可能です。

不透明な画素同士が重なっているかどうかを確認することで詳細な当たり判定を行う様子

具体的には、重なっている位置にある2つの画素(1つ目のキャラクターの画像の画素と2つ目のキャラクターの画像の画素)のアルファチャンネルが両方とも不透明に設定されている場合、その2つの画素は当たっていると判断することができます。ですので、そのような画素が1つでもあるのであれば、キャラクター同士は “当たっている” と判断することができます。

矩形として重なった部分のアルファチャンネルを調べて当たり判定を行う様子

逆に、そのような画素が1つもないのであれば、透明部分のみが重なっていることになりますので、キャラクター同士は “当たっていない” と判断することができます。

このような当たり判定を行うためには、ここまでの解説の中でも出てきたアルファチャンネルを当たり判定時に利用できるようにしておく必要があります。そのため、まず Character クラスのデータ属性にアルファチャンネルのデータを持たせるようにしたいと思います。

その後、そのアルファチャンネルを利用して、より詳細な当たり判定を行うようにしていきます。

スポンサーリンク

アルファチャンネルのデータ属性の追加

では、Character クラスにアルファチャンネルのデータをデータ属性として持たせるようにしていきたいと思います。

Character クラスでは、prepareImage メソッドで画像オブジェクトを生成していますので、prepareImage メソッドを変更してこのデータ属性を持たせるようにしていきたいと思います。

アルファチャンネルのみの画像オブジェクトの生成

これに関しては下記ページで解説しているのですが、PIL の画像オブジェクトの場合、split メソッドによりアルファチャンネルのみの画像オブジェクトを生成することができます。

Pythonでアルファチャンネルのみを取得する方法の解説ページアイキャッチ【Python】アルファチャンネルのみを画像から取得する方法(PIL・NumPy・OpenCV)

ただし、画像によってはアルファチャンネルが設定されていないものもありますので(画像フォーマットによってはそもそも設定不可なものもある)、アルファチャンネルが設定されていない場合は全ての画素が不透明設定されているものとして、自身でアルファチャンネルのみの画像オブジェクトを生成するようにしていきたいと思います。

現状 prepareImage メソッドの中では、拡大縮小後の画像オブジェクトとして resized_image、さらにそれを左右反転したオブジェクトとして mirrored_image を作成していますので、まずはこれらからアルファチャンネルのみの画像オブジェクトの生成を行いたいと思います。

具体的には、Character クラスの prepareImageメソッドを下記のように変更します。

アルファチャンネルのみの画像オブジェクト生成
class Character:

	def prepareImage(self, path, size, is_right=True):

		#略

		resized_image = image.resize(resize_size)
		mirrored_image = ImageOps.mirror(resized_image)

		#↓これを追加
		resized_channels = resized_image.split()
		if len(resized_channels) == 4:
			resized_alpha = resized_channels[3]
		else:
			resized_alpha = Image.new("L", (resized_image.width, resized_image.height), 255)

		mirrored_channels = mirrored_image.split()
		if len(mirrored_channels) == 4:
			mirrored_alpha = mirrored_channels[3]
		else:
			mirrored_alpha = Image.new("L", (mirrored_image.width, mirrored_image.height), 255)
		#↑これを追加

		#略

カラー画像の場合、アルファチャンネルが設定されている画像オブジェクトに対して split メソッドを実行すれば、長さが 4 のタプルが返却されます。さらに、そのタプルの第 3 要素がアルファチャンネルの画像オブジェクトになりますので、この画像オブジェクトがそのまま使えます。

その一方で、split メソッドが返却するタプルの長さが 4 でない場合、split メソッドを実行した画像オブジェクトではアルファチャンネルが設定されていないと判断できます。この場合、タプルの第 3 要素からのアルファチャンネルの画像オブジェクトの取得ができません。

そのため、別途 Image.new で新たな画像オブジェクトの生成を行なっています。そして、この生成した画像オブジェクトをアルファチャンネルの画像オブジェクトとして扱っていきます。

Image.new の第3引数に 255 を指定しているのは、全画素のアルファチャンネルを不透明に設定するためです。基本的に、アルファチャンネルに 255 が設定された画素が不透明な画素として扱われることが多いです(0 を指定した場合は、全画素が透明な画素として扱われるようになります)。

また、今回作成しているゲームにおいては、キャラクターの移動方向に合わせて表示する画像も左右反転したものを使用するようにしています。

当然左右反転前と左右反転後では各画素のアルファチャンネルの設定値が異なるため(アルファチャンネルの設定値も左右反転する)、resized_image とそれを左右反転させた mirrored_image それぞれからアルファチャンネルの画像オブジェクトを生成するようにしています。

MEMO

上記はキャラクターの画像がカラー画像の場合に限定した解説になります

また、スクリプトもカラー画像の場合に限定したものになっているのでご了承ください

例えばモノクロ画像をキャラクターの画像として使用する場合、4231 に置き換えて読む必要があるので注意してください

また、これに合わせてスクリプトの変更も必要になります

アルファチャンネルデータのデータ属性の追加

続いて実際に Character クラスにアルファチャンネルデータのデータ属性を追加していきたいと思います。

先ほどアルファチャンネルの画像オブジェクトを生成しましたが、今回はこの画像オブジェクト自体をデータ属性として持たせるのではなく、この画像オブジェクトのピクセル列をデータ属性として持たせるようにしたいと思います。

アルファチャンネル画像のピクセル列とは、要は、各座標の画素のアルファチャンネルの値が格納された1次元の配列です。

アルファチャンネルデータの構造の説明図

左上の画素から順にアルファチャンネルの値が格納されており、このピクセル列を alpha_data とすれば、座標 (x, y) のアルファチャンネルの値は alpha_data[y * 画像の幅 + x] から取得することができます。

さらに、その取得した値が 255 の場合、座標 (x, y) の画素は不透明であると判断することができます。

各座標のアルファチャンネルの取得は画像オブジェクトに getpixel メソッドを実行させることでも実現できますが、各画素に対して getpixel を毎回実行すると処理が遅くなってしまいます。

そのため、ピクセル列でデータを保持しておき、そのピクセル列から各画素のアルファチャンネルの値を取得するようにしたいと思います。

画像オブジェクトのピクセル列は getdata メソッドにより取得することができますので、Character クラスの prepareImage メソッドを下記のように変更することで、アルファチャンネル画像のピクセル列をデータ属性 left_alpharight_alpha に持たせることができるようになります(以降では、このアルファチャンネル画像のピクセル列をアルファチャンネルデータと呼ばせていただきます)。

アルファチャンネルデータのデータ属性
class Character:

	def prepareImage(self, path, size, is_right=True):

		#略

		if is_right:
			self.right_image = ImageTk.PhotoImage(resized_image)
			self.left_image = ImageTk.PhotoImage(mirrored_image)

			#↓これを追加
			self.right_alpha = resized_alpha.getdata()
			self.left_alpha = mirrored_alpha.getdata()
			#↑これを追加
		else:
			self.left_image = ImageTk.PhotoImage(resized_image)
			self.right_image = ImageTk.PhotoImage(mirrored_image)

			#↓これを追加
			self.left_alpha = resized_alpha.getdata()
			self.right_alpha = mirrored_alpha.getdata()
			#↑これを追加

下記ページの キャラクターの向きの調整 でも解説していますが、Character クラスのデータ属性 left_image は左方向移動時に描画されるキャラクターの画像であり、right_image は右方向移動時に描画されるキャラクターの画像となります。

横スクロールアクションゲームを作る(キャラクターの移動)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(キャラクターの移動)

同様に、上記で追加したデータ属性 left_alpha は左方向移動時に描画されるキャラクターの画像のアルファチャンネルデータであり、right_alpha は右方向移動時に描画されるキャラクターの画像のアルファチャンネルデータとなります。

スポンサーリンク

アルファチャンネルデータの取得

さらに、Character クラスにメソッドを追加して、キャラクターの移動方向に合わせたアルファチャンネルデータを取得できるようにしたいと思います。

具体的には、Character クラスに下記の getAlpha メソッドを追加します。

アルファチャンネルデータの取得
class Character:

	#↓これを追加
	def getAlpha(self):
		if self.direction == Character.DIRECTION_RIGHT:
			return self.right_alpha
		elif self.direction == Character.DIRECTION_LEFT:
			return self.left_alpha
	#↑これを追加

あとは、この getAlpha メソッドで取得されるアルファチャンネルデータを利用して当たり判定を行うようにすれば、詳細な当たり判定を実現できるようになります。

より詳細な当たり判定の実装

では、アルファチャンネルを利用して詳細な当たり判定を行うようにしていきたいと思います。

当たり判定は、下記ページの キャラクター同士の当たり判定 で作成した Character クラスの isCollided メソッドで行うようになっていますので、このメソッドを変更していくことになります。

横スクロールアクションゲームを作る(ゴールの作成)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(ゴールの作成)

まず簡単な復習ですが、キャラクター同士の当たり判定 においては、2つのキャラクターの画像が描画されている矩形に対し、下記が始点と終点となる「重なり矩形」を考えることで当たり判定を行いました。

  • 始点の x 座標:下記の2つの座標の大きい方
    • 1つ目の矩形の始点の x 座標
    • 2つ目の矩形の始点の x 座標
  • 始点の y 座標:下記の2つの座標の大きい方
    • 1つ目の矩形の始点の y 座標
    • 2つ目の矩形の始点の y 座標
  • 終点の x 座標:下記の2つの座標の小さい方
    • 1つ目の矩形の終点の x 座標
    • 2つ目の矩形の終点の x 座標
  • 終点の y 座標:下記の2つの座標の小さい方
    • 1つ目の矩形の終点の y 座標
    • 2つ目の矩形の終点の y 座標

この重なり矩形の始点が終点よりも左上側に存在する場合のみ、キャラクターが描画されている2つの矩形が “当たった” と判断できましたね!また、下の図からも分かるように、この重なり矩形は2つの矩形が重なっている部分の矩形であるとも言えます。

3つ目の矩形の始点と終点の位置関係から当たり判定を行う様子1

ただし、前述の通り、これで判断できるのは2つの矩形が重なっていることだけであって、本当に2つのキャラクターが当たったかどうかまでは判断できません。より具体的に言えば、2つのキャラクターの画像の不透明部分が重なっているかどうかが判断できません。

次は、この2つの矩形が重なっている部分、つまり重なり矩形内に存在する画素のアルファチャンネルを調べ、不透明部分同士が重なっているかどうかの判断を行うようにしていきたいと思います。

このような判断を行うためには、まず前述の方法により2つの矩形から重なり矩形の始点と終点を求め(これは大雑把な当たり判定でも行なっている)、

詳細な当たり判定を行う手順1

さらに重なり矩形の始点から終点までに存在する2つの画像の画素のアルファチャンネルを1つずつ調べていくことで実現することができます。

詳細な当たり判定を行う手順2

画素が不透明に設定されている場合、そのアルファチャンネルの値は 255 になりますので、もし同じ位置に存在する2つの画像の画素両方のアルファチャンネルが 255 の場合、その位置では各画像の不透明部分が重なっていることになります。

不透明部分が重なっている位置が1つでも存在すれば、それはキャラクターの画像同士で考えても不透明部分が重なっていることになりますので、2つのキャラクターは “当たっている” と判断することができます。

逆に不透明部分が重なっている位置が1つもないのであれば、透明部分のみが重なっていることになるので、2つのキャラクターは “当たっていない” と判断することができます。

上記のように判断を行うためには、元々大雑把に当たり判定を行なっていた Character クラスの isCollided メソッドを次のように変更すれば良いです。

透明度を考慮した当たり判定
class Character:

	def isCollided(self, opponent):
		if self.state == Character.STATE_CLEAR or self.state == Character.STATE_DEFEATED:
			return False
		if opponent.state == Character.STATE_CLEAR or opponent.state == Character.STATE_DEFEATED:
			return False

		sx = max(self.x, opponent.x)
		sy = max(self.y, opponent.y)
		ex = min(self.x + self.width, opponent.x + opponent.width)
		ey = min(self.y + self.height, opponent.y + opponent.height)

		if sx < ex and sy < ey:

			#↓これは削除
			#return True
			#↑これは削除

			#↓これを追加
			character_alpha = self.getAlpha()
			opponent_alpha = opponent.getAlpha()

			for y in range(sy, ey):
				for x in range(sx, ex):

					character_x = x - self.x
					character_y = y - self.y
					opponent_x = x - opponent.x
					opponent_y = y - opponent.y

					character_pos = character_y * self.width + character_x
					opponent_pos = opponent_y * opponent.width + opponent_x

					if character_alpha[character_pos] == 255:
						if opponent_alpha[opponent_pos] == 255:
							return True

			return False
			#↑これを追加

		else:
			False

追加した部分が、ここまで解説してきたアルファチャンネルを利用した詳細な当たり判定を行う処理となります。

isCollided メソッドの前半部分では、キャラクター同士の当たり判定 で解説した大雑把な当たり判定、つまり矩形同士が重なっているかどうかの判断を行なっています。

矩形同士が重なっていない場合は詳細な当たり判定は不要なため、追加した処理は矩形同士が重なっている場合のみ実行されるようにしています。

この詳細な当たり判定においては、まず getAlpha メソッドにより、isCollided メソッドを実行したオブジェクトと引数 opponent のオブジェクトのアルファチャンネルデータを取得し、あとは重なり矩形の始点から終点にかけてのループの中で、同じ位置にある画素のアルファチャンネルの値が両方 255 になっているかどうかの確認をしていっています。

このループ内において、character_x と character_y、さらには opponent_x と opponent_y の計算を行なっていますが、これはゲーム画面上の座標である xy をキャラクターの画像上の座標に変換するためです。

つまり、この計算の右辺に出てくる座標は全て、ゲーム画面の左上を原点 (0, 0) とする座標になります。それを、画像の左上を原点 (0, 0) とする座標に変換しています。

座標変換の説明図

これにより、重なり矩形内の座標 (x, y) に各画像のどの画素が位置しているかが分かるようになります。

また、アルファチャンネルデータはアルファチャンネルを格納した配列であり、画像上の座標 (a, b) のアルファチャンネルの値は アルファチャンネルデータ[a * 画像の幅 + b] により取得することができますので、この添字に相当する値を算出して character_posopponent_pos に格納しています。

あとは、character_alpha[character_pos]opponent_alpha[opponent_pos] の値がともに 255 であれば、座標 (x, y) の位置で2つのキャラクターの画像の不透明部分が重なっていると判断できます。なので、この場合は即座に “当たっている” と判断して True を返却するようにしています。

True を返却することなくループが終了した場合は、不透明な画素同士 or 透明な画素と不透明な画素が重なっていただけということになりますので、False を返却するようにしています。

上記の isCollided メソッドの変更により、詳細な当たり判定の実装は完了です。

変更後のスクリプトを実行して操作キャラクターとゴールを近づけた際、今までは透明部分が当たっただけでもゲームクリア画面が表示されていたのが、不透明部分が当たらないとゲームクリア画面が表示されなくなったことが確認できるのではないかと思います。

詳細な当たり判定導入前後のGAME CLEAR表示タイミングの違い

より詳細な踏みつけたかどうかの判断

透明度を利用して詳細な当たり判定を行うようになった結果、実は Character クラスの isTrampling メソッドで実施している「踏みつけたかどうかの判断」が上手く動作しなくなるという弊害が生まれています。

この「踏みつけたかどうかの判断」においても透明度を考慮した判断を行うようにすることで、正常に動作するようにしていきたいと思います。

スポンサーリンク

現状の踏みつけたかどうかの判断

今回作成するゲームにおいては、操作キャラクターと敵キャラクターが当たったとき、操作キャラクターの下半分のみが当たっている場合は “踏みつけた”、それ以外は “踏みつけられなかった” と判断するようにしようとしています。

現状では下の図のように、2つの矩形の重なった領域(重なり矩形)の始点よりも操作キャラクターの中心が上側にある場合、”踏みつけた” と判断するようにしています。

踏みつけたかどうかの判断の説明図

今までの大雑把な当たり判定では、2つの矩形同士が重なった時に当たったと判断されるので、上記のような方法でも踏みつけたかどうかをある程度うまく判断できました。

ただ、詳細な当たり判定を行うようになったので、実際にキャラクターの画像の不透明部分が重ならないと当たったと判断されないようになりました。これにより、実際には踏みつけたように見えるにも関わらず “踏みつけられなかった” と判断されるケースが発生しています。

大雑把な当たり判定と詳細な当たり判定の当たったかどうかの判断の仕方の違い

上の図の右側の画像は分かりやすい例で、猫の背中を踏みつけているように見えますが、実際に2つの矩形の重なった領域の始点とキャラクターの中心の縦方向の座標を比べると、キャラクターの中心の方が下側に存在しています。

踏みつけているように見えるが踏みつけられなかったと判断されてしまう例

そのため、操作キャラクターが敵キャラクターを踏みつけているように見えるのにも関わらず、現状の判断の仕方だと “踏みつけられなかった” と判断されてしまいます。

透明度を考慮した踏みつけたかどうかの判断

このように誤った判断が行われてしまう原因は、当たり判定を透明度を考慮して行うようにしたにも関わらず、踏みつけたかどうかを下記の2つの座標の上下関係のみから判断を行なっている点にあります。

  • キャラクターの中心
  • 重なった領域の始点

先ほどもお見せした下の図を見ても分かるように、実際には敵キャラクターとは操作キャラクターの下半分しか当たっていないですよね…。このような場合であっても、座標だけから判断すると踏みつけられなかったと判断されてしまいます。

踏みつけているように見えるが踏みつけられなかったと判断されてしまう例

正しく判断を行うようにするためには、これも結局は実際に操作キャラクターの上半分が敵キャラクターと当たっているかどうかを判断する必要があります。つまり、操作キャラクターの上半分の不透明部分と、敵キャラクターの不透明部分とが当たっているかどうかを判断する必要があります。

要は、下の図における始点から終点にかけて より詳細な当たり判定の実装 で行なったことと同等のことを行ない、本当に踏みつけられなかったのかどうかを判断する必要があるということです。

踏みつけたかどうかを判断するために調べる必要のある領域を示す始点と終点

そして、操作キャラクターの上半分の不透明部分が敵キャラクターの不透明部分と当たっていない場合は “踏みつけた” と判断し、当たっている場合は “踏みつけられなかった” と判断するようにすることで、実際のキャラクター同士の重なり具合に合った判断ができるようになります。

前述の通り、踏みつけたかどうかを判断する際に より詳細な当たり判定の実装 で行なったことと同等のことを行えば良いのですから、Character クラスの isTrampling メソッドを下記のように変更することで、正しく踏みつけたかどうかの判断を行うようにすることができます。

透明度を考慮した踏みつけたかどうかの判断
class Character:

	def isTrampling(self, opponent):
		if self.jump_state == Character.JUMP_NO:
			return False

		sy = max(self.y, opponent.y)

		#↓これを追加
		sx = max(self.x, opponent.x)
		ex = min(self.x + self.width, opponent.x + opponent.width)
		#↑これを追加

		if self.y + self.height / 2 < sy:
			return True

		else:
			#↓これは削除
			#return False
			#↑これは削除

			#↓これを追加
			character_alpha = self.getAlpha()
			opponent_alpha = opponent.getAlpha()

			for y in range(sy, self.y + self.height // 2):
				for x in range(sx, ex):

					character_x = x - self.x
					character_y = y - self.y
					opponent_x = x - opponent.x
					opponent_y = y - opponent.y

					character_pos = character_y * self.width + character_x
					opponent_pos = opponent_y * opponent.width + opponent_x

					if character_alpha[character_pos] == 255:
						if opponent_alpha[opponent_pos] == 255:
							return False
			return True
			#↑これを追加

isTrampling メソッドは、実行したオブジェクトのキャラクターが引数 opponent のオブジェクトのキャラクターを踏みつけているかどうかを判断するメソッドです。前者のキャラクターが後者のキャラクターを踏みつけた場合は True を返却し、それ以外の場合、すなわち前者のキャラクターが後者のキャラクターを踏みつけられなかった場合は False を返却します。

self.y + self.height / 2 < sy を満たす場合は、そもそも操作キャラクターの下半分しか敵キャラクターと当たっていないことになるので、この場合は “踏みつけた” と判断することができます。なので True を返却してメソッドは終了できます。

それ以外の場合は、操作キャラクターの上半分が敵キャラクターに当たっている可能性があるので、より詳細な当たり判定の実装 で変更した Character クラスの isCollided メソッドとほぼ同様の処理により、実際に不透明部分同士が当たっているかどうかを調べていくようにしています。

ただし、この isTrampling メソッドで調べる必要があるのはキャラクターの上半分のみなので、調べる範囲(ループの範囲)が異なります。また、不透明部分が当たっている場合は “踏みつけられなかった” と判断することになるため False を返却するようにしています。この返却値が isCollided メソッドとは逆になっているので注意してください。

こういった違いはあるものの、isTrampling メソッドと isCollided メソッドでは共通の処理が多いので、この共通の処理部分を括り出してメソッド化したいと思います。

具体的には、まず下記の isCollidedInDetail メソッドを Character クラスに追加します。

詳細な当たり判定のメソッド化
class Character:

	#↓これを追加
	def isCollidedInDetail(self, opponent, rect):
		character_alpha = self.getAlpha()
		opponent_alpha = opponent.getAlpha()

		sx, sy, ex, ey = rect

		for y in range(sy, ey):
			for x in range(sx, ex):

				character_x = x - self.x
				character_y = y - self.y
				opponent_x = x - opponent.x
				opponent_y = y - opponent.y

				character_pos = character_y * self.width + character_x
				opponent_pos = opponent_y * opponent.width + opponent_x

				if character_alpha[character_pos] == 255:
					if opponent_alpha[opponent_pos] == 255:
						return True

		return False
	#↑これを追加

さらに、Character クラスの isCollided メソッドと isTrampling メソッドを下記のように変更します。

詳細な当たり判定のメソッド呼び出し
class Character:

	def isCollided(self, opponent):
		if self.state == Character.STATE_CLEAR or self.state == Character.STATE_DEFEATED:
			return False
		if opponent.state == Character.STATE_CLEAR or opponent.state == Character.STATE_DEFEATED:
			return False

		sx = max(self.x, opponent.x)
		sy = max(self.y, opponent.y)
		ex = min(self.x + self.width, opponent.x + opponent.width)
		ey = min(self.y + self.height, opponent.y + opponent.height)

		if sx < ex and sy < ey:
			#↓これを追加
			return self.isCollidedInDetail(opponent, (sx, sy, ex, ey))
			#↑これを追加

			#↓これは削除
			#character_alpha = self.getAlpha()
			#opponent_alpha = opponent.getAlpha()

			#略
			
			#return False
			#↑これは削除
			
		else:
			return False

	def isTrampling(self, opponent):
		if self.jump_state == Character.JUMP_NO:
			return False

		sy = max(self.y, opponent.y)
		sx = max(self.x, opponent.x)
		ex = min(self.x + self.width, opponent.x + opponent.width)

		if self.y + self.height / 2 < sy:
			return True

		else:
			#↓これを追加
			return not self.isCollidedInDetail(opponent, (sx, sy, ex, self.y + self.height // 2))
			#↑これを追加

			#↓これは削除
			#character_alpha = self.getAlpha()
			#opponent_alpha = opponent.getAlpha()

			#略
			
			#return True
			#↑これは削除

共通の処理がメソッド化されたので各メソッドがスッキリしたと思います。

isTrampling メソッドの場合は、isCollidedInDetail の返却値に対して not を取ったものを返却する必要があるので注意してください。

より詳細な当たり判定の負荷削減

ここまでの変更により、キャラクター同士の当たり判定をより詳細に行うようにすることができました。

ですが、実はこの詳細な当たり判定はかなり処理が重いです。特に、下の図のように、透明な部分のみが重なった時、重なった領域全ての画素のアルファチャンネルを調べる必要があるため特に処理が重くなります。

特に当たり判定が重くなる場合の例を示す図

特にキャラクターの数が多くなれば多くなるほど処理が重くなってしまいますので、このページの最後として、詳細な当たり判定による処理の負荷を削減し、軽い処理で当たり判定できるようにしていきたいと思います。

スポンサーリンク

画像の外側の透明な画素を削って負荷削減

まず、画像の外側の不要な透明な画素を削って負荷を削減していきたいと思います。

まず、ゲーム内に登場するキャラクターの1つ1つの画像に注目してみるとキャラクターの絵の周りに余分に透明な画素が設けられていることが確認できると思います。もちろん用意した画像にもよるのですが、このように絵の周りに透明な画素が設けられていることが多いと思います

キャラクターの画像の外側に余分な透明な画素が存在する例

これらの画素は透明なのですから、あってもなくても見た目には影響のない画素になります。

もし、これらの絵の周りにある外側の透明な画素を削り、その分画像を小さくしたとしたら当たり判定時の負荷はどうなるでしょうか?

余分な透明な画素を削った様子を示す図

外側の透明の画素を削った場合、この節の最初に挙げた例においては、下の図のように重なる領域が小さくなることになります。従って、アルファチャンネルを調べる必要のある画素数が減り、詳細な当たり判定時の負荷が減ります。

余分な画素を削ることでアルファチャンネルを調べる必要のある領域が小さくなった様子

もちろん外側の透明な画素を削ることで、今までは矩形が重なっていたのが、重ならなくなるようになることもあります。

余分な画素を削ることでアルファチャンネルを調べる必要のある領域が無くなった様子

つまり、キャラクターの画像の外側の透明な画素を取り除いてその分小さな画像にすることで、詳細な当たり判定の負荷を軽減し、処理効率を向上することができます。

PIL においては、画像の必要な部分のみを切り出して新たな画像を生成するメソッドとして crop が用意されています。引数には切り出したい位置を示す座標を指定する必要があります(矩形の始点と終点の座標を指定する。切り出された画像には終点の座標は含まれない)。

cropメソッドの説明図

さらに PIL には、ここまで登場してきた画像の外側の余分な透明部分を取り除いた時の矩形の座標(上の図における (sx, sy, ex, ey))を取得するためのメソッドとして getbbox が用意されています。

したがって、まずは getbbox で矩形の座標を取得し、さらにその矩形の座標を指定して crop メソッドを実行することで、画像から外側の余分な透明部分を取り除くことができます。

ということで、ここまで解説してきた内容を実際に実装していきましょう!

画像オブジェクトを生成するのは Character クラスの prepareImage メソッドであり、拡大縮小後の画像オブジェクトを resized_image が参照しています。この resized_imagebbox メソッドと crop メソッドを実行させることで、resized_image の画像から余分な透明画素を取り除くようにしていきたいと思います。

これを行うためには、Character クラスの prepareImage メソッドを下記のように変更すれば良いです。

不要な透明な画素をcropで削る
class Character:

	def prepareImage(self, path, size, is_right=True):
		image = Image.open(path)

		width, height = size
		ratio = min(width / image.width, height / image.height)
		resize_size = (round(ratio * image.width), round(ratio * image.height))
		resized_image = image.resize(resize_size)

		#↓これを追加
		crop_rect = resized_image.getbbox()
		resized_image = resized_image.crop(crop_rect)	
		#↑これを追加

		mirrored_image = ImageOps.mirror(resized_image)

		#略

追加した部分が実行されると、resized_image が参照する画像に対して不要な透明な画素を削るためのクロップが実行され、さらにそのクロップ後の画像オブジェクトを resized_image が参照することになります。

以降、この resized_image から左右反転した画像オブジェクトの生成やアルファチャンネルデータの生成、さらには tkinter 用の画像オブジェクトの生成等が行われますが、全て不要な透明な画素を削った後の画像オブジェクトに対して実行されることになります。

上記のように変更しても、ゲームの動作の見た目としては変わらないかもしれませんが、敵を増やして変更前後の CPU 使用率を確認してみれば、結構効果が出ていることは確認できると思います。

例えば私の環境では、敵キャラクターの数を20にして確認してみたところ、変更前はこのアプリの CPU 使用率が 75 % 程度だったものが、変更後は 40 % 程度まで下がりました。

粗く判定することで負荷削減

次はもっと単純で簡単に詳細な当たり判定の負荷を削減したいと思います。

やり方は簡単で、Character クラスの isCollidedInDetail メソッドの for 分部分を下記のように変更します。

粗く詳細な当たり判定を実施する
class Character:

	def isCollidedInDetail(self, opponent, rect):
		character_alpha = self.getAlpha()
		opponent_alpha = opponent.getAlpha()

		sx, sy, ex, ey = rect

		for y in range(sy, ey, 2): #←ここを変更
			for x in range(sx, ex, 2): #←ここを変更

縦方向と横方向ともに1画素飛ばしで詳細な当たり判定を行うように変更しています。アルファチャンネルを調べる画素数が減るので、当然当たり判定の処理効率が向上します。

もちろんこれで当たり判定の精度は若干下がってしまうのですが、動いてる画像に対する1画素程度の誤差は人の目ではほぼ認識できないと思いますので、これでも十分な当たり判定は行えるのではないかと思います。

どうしても当たり判定の処理が重い場合は、上記の変更をしてみると良いと思います!

このページで作成したスクリプト

以上で、このページの解説は終了です。

最後に、ここまでの解説を踏まえて作成したスクリプトの全体を下記に掲載しておきます。次のページではこのスクリプトをベースに解説を進めていきたいと思います。

このページで作成したスクリプト
import tkinter
from PIL import Image, ImageTk, ImageOps
import random

# アプリの設定
VIEW_WIDTH = 600
VIEW_HEIGHT = 400
GAME_WIDTH = 1500

UPDATE_TIME = 100

NUM_CAT_ENEMY = 3
NUM_DOG_ENEMY = 3

ROUGHNESS_DETECT = 1

BG_IMAGE_PATH = "bg_natural_sougen.jpeg"
PLAYER_IMAGE_PATH = "hashiru_boy.png"
GOAL_IMAGE_PATH = "car_animals_flag.png"
CAT_ENEMY_IMAGE_PATH = "cat_wink_brown.png"
DOG_ENEMY_IMAGE_PATH = "jitensya_inu.png"


class Character:

    DIRECTION_LEFT = 0
    DIRECTION_RIGHT = 1
    DIRECTION_UP = 2

    JUMP_NO = 0
    JUMP_UP = 1
    JUMP_DOWN = 2
    JUMP_TRAMPLE = 3

    STATE_NORMAL = 0
    STATE_CLEAR = 1
    STATE_DEFEATED = 2

    def __init__(self, path, size, is_right=True):
        self.prepareImage(path, size, is_right)

        self.base_y = VIEW_HEIGHT - self.right_image.height()
        self.x = 0
        self.y = self.base_y
        self.speed_x = 30
        self.speed_y = 20
        self.jump_state = Character.JUMP_NO
        self.jump_height = 200
        self.direction = Character.DIRECTION_RIGHT
        self.state = Character.STATE_NORMAL
        self.trample_height = 50
        self.trample_y = 0

    def getImage(self):
        if self.direction == Character.DIRECTION_RIGHT:
            return self.right_image
        elif self.direction == Character.DIRECTION_LEFT:
            return self.left_image

    def getAlpha(self):
        if self.direction == Character.DIRECTION_RIGHT:
            return self.right_alpha
        elif self.direction == Character.DIRECTION_LEFT:
            return self.left_alpha

    def prepareImage(self, path, size, is_right=True):
        image = Image.open(path)

        width, height = size
        ratio = min(width / image.width, height / image.height)
        resize_size = (round(ratio * image.width), round(ratio * image.height))
        resized_image = image.resize(resize_size)
        crop_rect = resized_image.getbbox()
        resized_image = resized_image.crop(crop_rect)
        mirrored_image = ImageOps.mirror(resized_image)

        resized_channels = resized_image.split()
        if len(resized_channels) == 4:
            resized_alpha = resized_channels[3]
        else:
            resized_alpha = Image.new("L", (resized_image.width, resized_image.height), 255)

        mirrored_channels = mirrored_image.split()
        if len(mirrored_channels) == 4:
            mirrored_alpha = mirrored_channels[3]
        else:
            mirrored_alpha = Image.new("L", (mirrored_image.width, mirrored_image.height), 255)

        if is_right:
            self.right_image = ImageTk.PhotoImage(resized_image)
            self.left_image = ImageTk.PhotoImage(mirrored_image)
            self.right_alpha = resized_alpha.getdata()
            self.left_alpha = mirrored_alpha.getdata()
        else:
            self.left_image = ImageTk.PhotoImage(resized_image)
            self.right_image = ImageTk.PhotoImage(mirrored_image)
            self.left_alpha = resized_alpha.getdata()
            self.right_alpha = mirrored_alpha.getdata()

        self.width = self.right_image.width()
        self.height = self.right_image.height()

    def move(self, direction):
        if self.state == Character.STATE_CLEAR or self.state == Character.STATE_DEFEATED:
            return

        if direction == Character.DIRECTION_LEFT:
            self.x = max(0, self.x - self.speed_x)
            self.direction = Character.DIRECTION_LEFT
        elif direction == Character.DIRECTION_RIGHT:
            self.x = min(GAME_WIDTH - self.right_image.width(), self.x + self.speed_x)
            self.direction = Character.DIRECTION_RIGHT
        elif direction == Character.DIRECTION_UP:
            if self.jump_state == Character.JUMP_NO:
                self.jump_state = Character.JUMP_UP

    def update(self):
        if self.state == Character.STATE_CLEAR or self.state == Character.STATE_DEFEATED:
            return

        if self.jump_state == Character.JUMP_UP:
            self.y -= self.speed_y
            if self.y <= self.base_y - self.jump_height:
                self.jump_state = Character.JUMP_DOWN
                self.y = self.base_y - self.jump_height
        elif self.jump_state == Character.JUMP_TRAMPLE:
            self.y -= self.speed_y
            if self.y <= self.trample_y - self.trample_height:
                self.jump_state = Character.JUMP_DOWN
                self.y = self.trample_y - self.trample_height
        elif self.jump_state == Character.JUMP_DOWN:
            self.y += self.speed_y
            if self.y >= self.base_y:
                self.jump_state = Character.JUMP_NO
                self.y = self.base_y

    def isCollidedInDetail(self, opponent, rect):
        character_alpha = self.getAlpha()
        opponent_alpha = opponent.getAlpha()

        sx, sy, ex, ey = rect

        for y in range(sy, ey, ROUGHNESS_DETECT):
            for x in range(sx, ex, ROUGHNESS_DETECT):

                character_x = x - self.x
                character_y = y - self.y
                opponent_x = x - opponent.x
                opponent_y = y - opponent.y

                character_pos = character_y * self.width + character_x
                opponent_pos = opponent_y * opponent.width + opponent_x

                if character_alpha[character_pos] == 255:
                    if opponent_alpha[opponent_pos] == 255:
                        return True

        return False

    def isCollided(self, opponent):
        if self.state == Character.STATE_CLEAR or self.state == Character.STATE_DEFEATED:
            return False
        if opponent.state == Character.STATE_CLEAR or opponent.state == Character.STATE_DEFEATED:
            return False

        sx = max(self.x, opponent.x)
        sy = max(self.y, opponent.y)
        ex = min(self.x + self.width, opponent.x + opponent.width)
        ey = min(self.y + self.height, opponent.y + opponent.height)

        if sx < ex and sy < ey:
            return self.isCollidedInDetail(opponent, (sx, sy, ex, ey))
        else:
            return False

    def defeated(self):
        self.state = Character.STATE_DEFEATED

    def trample(self):
        self.jump_state = Character.JUMP_TRAMPLE
        self.trample_y = self.y

    def isTrampling(self, opponent):
        if self.jump_state == Character.JUMP_NO:
            return False

        sy = max(self.y, opponent.y)
        sx = max(self.x, opponent.x)
        ex = min(self.x + self.width, opponent.x + opponent.width)

        if self.y + self.height / 2 < sy:
            return True
        else:
            return not self.isCollidedInDetail(opponent, (sx, sy, ex, self.y + self.height // 2))


class Player(Character):

    def __init__(self):
        super().__init__(PLAYER_IMAGE_PATH, (100, 100))

    def gameClear(self):
        self.state = Character.STATE_CLEAR


class Enemy(Character):

    def __init__(self, path, size, is_right=True):
        super().__init__(path, size, is_right)

        self.x = random.randrange(300, GAME_WIDTH - 300)
        self.direction = Character.DIRECTION_LEFT

    def update(self):
        self.move(self.direction)

        if random.randrange(10) % 10 == 0:
            self.move(Character.DIRECTION_UP)

        if self.x == 0:
            self.direction = Character.DIRECTION_RIGHT
        elif self.x == GAME_WIDTH - self.width:
            self.direction = Character.DIRECTION_LEFT

        super().update()


class CatEnemy(Enemy):

    def __init__(self):
        super().__init__(CAT_ENEMY_IMAGE_PATH, (80, 80))

        self.jump_height = 70
        self.speed_x = 20


class DogEnemy(Enemy):

    def __init__(self):
        super().__init__(DOG_ENEMY_IMAGE_PATH, (80, 80), False)

        self.jump_height = 50
        self.speed_x = 25


class Goal(Character):

    def __init__(self):
        super().__init__(GOAL_IMAGE_PATH, (200, 200), False)

        self.direction = Character.DIRECTION_LEFT
        self.x = GAME_WIDTH - self.width


class Screen:

    TYPE_GAMECLEAR = 0
    TYPE_GAMEOVER = 1

    def __init__(self, master):
        self.master = master

        self.view_width = VIEW_WIDTH
        self.view_height = VIEW_HEIGHT
        self.game_width = GAME_WIDTH
        self.game_height = self.view_height
        self.draw_images = []

        self.createWidgets()
        self.drawBackground()

    def createWidgets(self):
        self.canvas = tkinter.Canvas(
            self.master,
            width=self.view_width,
            height=self.view_height,
            scrollregion=(
                0, 0, self.game_width, self.game_height
            ),
            highlightthickness=0
        )
        self.canvas.grid(column=0, row=0)

    def drawBackground(self):
        image = Image.open(BG_IMAGE_PATH)

        size = (self.game_width, self.game_height)
        resized_image = image.resize(size)

        self.bg_image = ImageTk.PhotoImage(resized_image)

        self.canvas.create_image(
            0, 0,
            anchor=tkinter.NW,
            image=self.bg_image
        )

    def update(self, image_infos, player_x):
        for draw_image in self.draw_images:
            self.canvas.delete(draw_image)

        self.draw_images.clear()

        for image, x, y in image_infos:

            draw_image = self.canvas.create_image(
                x, y,
                anchor=tkinter.NW,
                image=image
            )
            self.draw_images.append(draw_image)

        scroll_x = (player_x - self.view_width / 2) / self.game_width
        self.canvas.xview_moveto(max(0, scroll_x))

    def message(self, type, player_x):
        if player_x < self.view_width / 2:
            x = self.view_width // 2
        elif player_x >= self.game_width - self.view_width / 2:
            x = self.game_width - self.view_width // 2
        else:
            x = player_x
        y = self.game_height // 2

        if type == Screen.TYPE_GAMECLEAR:
            self.canvas.create_text(
                x, y,
                font=("", 40),
                fill="blue",
                text="GAME CLEAR",
                anchor=tkinter.CENTER
            )
        elif type == Screen.TYPE_GAMEOVER:
            self.canvas.create_text(
                x, y,
                font=("", 40),
                fill="red",
                text="GAME OVER",
                anchor=tkinter.CENTER
            )


class Game:

    def __init__(self, master):
        self.master = master
        self.screen = Screen(self.master)

        self.characters = []
        self.player = Player()
        self.characters.append(self.player)

        goal = Goal()
        self.characters.append(goal)

        for _ in range(NUM_CAT_ENEMY):
            enemy = CatEnemy()
            self.characters.append(enemy)

        for _ in range(NUM_DOG_ENEMY):
            enemy = DogEnemy()
            self.characters.append(enemy)

        self.master.bind("<KeyPress-Left>", self.press)
        self.master.bind("<KeyPress-Right>", self.press)
        self.master.bind("<KeyPress-Up>", self.press)

        self.update()

    def update(self):
        if self.player.state == Character.STATE_NORMAL:
            self.master.after(UPDATE_TIME, self.update)
        else:
            self.master.unbind("<KeyPress-Left>")
            self.master.unbind("<KeyPress-Right>")
            self.master.unbind("<KeyPress-Up>")

        for character in self.characters:
            character.update()
            self.collisionDetect(character)

        image_infos = []
        for character in self.characters:

            if character.state != Character.STATE_DEFEATED:
                image = character.getImage()
                image_info = (image, character.x, character.y)
                image_infos.append(image_info)

        self.screen.update(image_infos, self.player.x + self.player.width / 2)

        if self.player.state == Character.STATE_CLEAR:
            self.screen.message(Screen.TYPE_GAMECLEAR,
                                self.player.x + self.player.width // 2)
        elif self.player.state == Character.STATE_DEFEATED:
            self.screen.message(Screen.TYPE_GAMEOVER,
                                self.player.x + self.player.width // 2)

    def press(self, event):
        if event.keysym == "Left":
            self.player.move(Character.DIRECTION_LEFT)

        elif event.keysym == "Right":
            self.player.move(Character.DIRECTION_RIGHT)

        elif event.keysym == "Up":
            self.player.move(Character.DIRECTION_UP)

        self.collisionDetect(self.player)

    def collide(self, character, opponent):
        if isinstance(character, Player) and isinstance(opponent, Goal):
            character.gameClear()
        elif isinstance(character, Goal) and isinstance(opponent, Player):
            opponent.gameClear()
        elif isinstance(character, Enemy) and isinstance(opponent, Enemy):
            if character.direction != opponent.direction:
                if character.direction == Character.DIRECTION_LEFT:
                    character.move(Character.DIRECTION_RIGHT)
                    opponent.move(Character.DIRECTION_LEFT)
                else:
                    character.move(Character.DIRECTION_LEFT)
                    opponent.move(Character.DIRECTION_RIGHT)
            else:
                if character.direction == Character.DIRECTION_LEFT:
                    if character.x < opponent.x:
                        opponent.move(Character.DIRECTION_RIGHT)
                    else:
                        character.move(Character.DIRECTION_RIGHT)
                else:
                    if character.x > opponent.x:
                        opponent.move(Character.DIRECTION_LEFT)
                    else:
                        character.move(Character.DIRECTION_LEFT)
        elif isinstance(character, Enemy) and isinstance(opponent, Goal):
            if character.direction == Character.DIRECTION_LEFT:
                character.move(Character.DIRECTION_RIGHT)
            else:
                character.move(Character.DIRECTION_LEFT)
        elif isinstance(character, Goal) and isinstance(opponent, Enemy):
            if opponent.direction == Character.DIRECTION_LEFT:
                opponent.move(Character.DIRECTION_RIGHT)
            else:
                opponent.move(Character.DIRECTION_LEFT)
        elif isinstance(character, Player) and isinstance(opponent, Enemy):
            if character.isTrampling(opponent):
                opponent.defeated()
                character.trample()
            else:
                character.defeated()
        elif isinstance(character, Enemy) and isinstance(opponent, Player):
            if opponent.isTrampling(character):
                character.defeated()
                opponent.trample()
            else:
                opponent.defeated()

    def collisionDetect(self, character):
        for opponent in self.characters:
            if opponent is character:
                continue
            
            if character.isCollided(opponent):
                self.collide(character, opponent)


def main():
    app = tkinter.Tk()
    game = Game(app)
    app.mainloop()


if __name__ == "__main__":
    main()

基本的には、ここまで紹介してきたスクリプトを寄せ集めてきたものですが、粗く判定することで負荷削減 で紹介した詳細な当たり判定時に飛ばす画素の数を下記のグローバル変数で変更できるようにしています。

  • ROUGHNESS_DETECT:詳細な当たり判定時に飛ばす画素の数

もし、ここまでの解説の中でこれらを自身の環境に合わせて変更された方は、こちらの定義値も変更しておく必要があるので注意してください。

スポンサーリンク

まとめ

このページでは、横スクロールアクションゲームにおける「より詳細な当たり判定」について解説しました。

大雑把な当たり判定と比較してもうちょっとまともな当たり判定が行えるようになったと思います。ただ敵キャラクターを踏みつけたつもりでもまだゲームオーバーになってしまうことがあるので、特に踏みつけたかどうかの判定に関しては改善の余地はあるかなぁと思います。

今回はゲームの作り方というより、画像の扱いについての解説みたいな感じになってしまいましたが、画像が扱えると作れるプログラムやアプリの幅も広がりますので今回の解説内容は覚えておいて損はないと思います!

特に PIL は tkinter と相性が良い(簡単に PIL の画像オブジェクトから tkinter の画像オブジェクトに変換できる)ので、アプリを作る際に画像を扱いたい場合は利用してみると良いと思います。

次は、「横スクロールアクションゲームの作り方」の解説ページの最後として、ここまで作成してきたゲームをカスタマイズする例を紹介していきたいと思います。

横スクロールアクションゲームを作る(カスタマイズ例)の解説ページアイキャッチ【Python/tkinter】横スクロールアクションゲームを作る(カスタマイズ例)