【Python/tkinter】ブロック崩しゲームの作り方

このページにはプロモーションが含まれています

このページでは、Python で tkinter を用いた「ブロック崩しゲームの作り方」について解説していきます。

出来上がりは下の動画のようなものになります。

少し設定を変更すれば、下の動画のようなものに変化させることもできます。

ブロック崩しゲームとはまた異なりますが、ブロックが消えていく様子とボールが反射していく様子が気持ちよくて、個人的にはこっちの方が好きです。

上記の動画で紹介したようなゲームを作ってみたいという方は、是非ページを読み進めていっていただければと思います!

作成するブロック崩しゲームの紹介

まずは、作成する「ブロック崩しゲーム」がどのようなものであるのかについて説明しておきます

このブロック崩しゲームで登場するオブジェクトは下記の3種類となります。

  • ボール
  • パドル
  • ブロック
    ブロック崩しに登場するオブジェクトの説明図

ゲームを開始する際にはマウスでクリックを行います(どのボタンをクリックするのでも良いです)。ゲームを開始すれば、ボールが移動し、パドルを操作することができるようになります。

パドルに関してはマウスカーソルの位置に応じて横方向に移動します。

パドルがマウスカーソルの位置に応じて移動する様子

また、ボールはある方向に向かって定期的に移動し、パドルや壁(下方向以外の画面の端)に当たった場合は、ボールが反射し、別の方向に移動していくことになります。

ボールが反射する様子

ボールとブロックが当たった時もボールが反射することは同じですが、この際には当たったブロックを消すことができます。

ブロックが消える様子

全てのブロックを消すことができた場合はゲームクリアとなります。

その一方で、ボールが画面の下まで移動してしまった場合は、そのボールが消えることになります。全てのボールが消えてしまった場合はゲームオーバーとなります。

そうならないように、パドルをうまく操作してボールを反射させていく必要があります。

ちなみに今回は、ボールとブロック及びボールとパドルの当たり判定は「円と矩形」ではなく、「矩形同士」での判定とさせていただいています。ただし、それでもそれなりに上手く動作しているように見えると思います!

ブロック崩しゲームのスクリプト

今回作成するブロック崩しゲームのスクリプトの最終形は下記のようになります。

次の ブロック崩しゲームの作り方 においては、このスクリプトを作っていく上での過程を説明していきたいと思います。

ブロック崩しゲーム
import tkinter
import math

NUM_H_BLOCK = 10  # ブロッックの数(横方向)
NUM_V_BLOCK = 10  # ブロックの数(縦方向)
WIDTH_BLOCK = 40  # ブロックの幅
HEIGHT_BLOCK = 20  # ブロックの高さ
COLOR_BLOCK = "blue"  # ブロックの色

HEIGHT_SPACE = 300  # 縦方向の空きスペース

WIDTH_PADDLE = 200  # パドルの幅
HEIGHT_PADDLE = 20  # パドルの高さ
Y_PADDLE = 50  # パドルの下方向からの位置
COLOR_PADDLE = "green"  # パドルの色

RADIUS_BALL = 10  # ボールの半径
COLOR_BALL = "red"  # ボールの色
NUM_BALL = 2  # ボールの数

UPDATE_TIME = 20  # 更新間隔(ms)


class Ball:
	def __init__(self, x, y, radius, x_min, y_min, x_max, y_max):
		'''ボール作成'''

		# 位置と半径と移動可能範囲を設定
		self.x = x
		self.y = y
		self.r = radius
		self.x_min = x_min
		self.x_max = x_max
		self.y_min = y_min
		self.y_max = y_max

		# 一度に移動する距離(px)
		self.speed = 10

		# 移動方向を設定
		self.angle = math.radians(30)
		self.dx = self.speed * math.cos(self.angle)
		self.dy = self.speed * math.sin(self.angle)

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.r, self.y - self.r, self.x + self.r, self.y + self.r)

	def move(self):
		'''移動'''

		# 移動方向に移動
		self.x += self.dx
		self.y += self.dy

		if self.x < self.x_min:
			# 左の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_min

		elif self.x > self.x_max:
			# 右の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_max

		if self.y < self.y_min:
			# 上の壁とぶつかった

			# 縦方向に反射
			self.reflectV()
			self.y = self.y_min

		# elif self.y > self.y_max:
			# 下の壁とぶつかった

			# 縦方向に反射
			# self.reflectV()
			#self.y = self.y_max

	def turn(self, angle):
		'''移動方向をangleに応じて設定'''

		self.angle = angle
		self.dx = self.speed * math.cos(self.angle)
		self.dy = self.speed * math.sin(self.angle)

	def reflectH(self):
		'''横方向に対して反射'''

		self.turn(math.atan2(self.dy, -self.dx))

	def reflectV(self):
		'''縦方向に対して反射'''

		self.turn(math.atan2(-self.dy, self.dx))

	def getCollisionCoords(self, object):
		'''objectと当たった領域の座標の取得'''

		# 各オブジェクトの座標を取得
		ball_x1, ball_y1, ball_x2, ball_y2 = self.getCoords()
		object_x1, object_y1, object_x2, object_y2 = object.getCoords()

		# 新たな矩形の座標を取得
		x1 = max(ball_x1, object_x1)
		y1 = max(ball_y1, object_y1)
		x2 = min(ball_x2, object_x2)
		y2 = min(ball_y2, object_y2)

		if x1 < x2 and y1 < y2:
			# 始点が終点よりも左上にある

			# 当たった領域の左上座標と右上座標を返却
			return (x1, y1, x2, y2)
		else:

			# 当たっていないならNoneを返却
			return None

	def reflect(self, object):
		'''当たった方向に応じて反射'''

		# 各オブジェクトの座標を取得
		object_x1, object_y1, object_x2, object_y2 = object.getCoords()

		# 重なった領域の座標を取得
		x1, y1, x2, y2 = self.getCollisionCoords(object)

		is_collideV = False
		is_collideH = False

		# どの方向からボールが当たったかを判断
		if self.dx < 0:
			# ボールが左方向に移動中
			if x2 == object_x2:
				# objectの左側と当たった
				is_collideH = True
		else:
			# ボールが右方向に移動中
			if x1 == object_x1:
				# objectの右側と当たった
				is_collideH = True

		if self.dy < 0:
			# ボールが上方向に移動中
			if y2 == object_y2:
				# objectの下側と当たった
				is_collideV = True
		else:
			# ボールが下方向に移動中
			if y1 == object_y1:
				# objectの上側と当たった
				is_collideV = True

		if is_collideV and is_collideH:
			# 横方向と縦方向両方から当たった場合
			if x2 - x1 > y2 - y1:
				# 横方向の方が重なりが大きいので横方向に反射
				self.reflectV()
			elif x2 - x1 < y2 - y1:
				# 縦方向の方が重なりが大きいので縦方向に反射
				self.reflectH()
			else:
				# 両方同じなので両方向に反射
				self.reflectH()
				self.reflectV()

		elif is_collideV:
			# 縦方向のみ当たったので縦方向に反射
			self.reflectV()

		elif is_collideH:
			# 横方向のみ当たったので横方向に反射
			self.reflectH()

	def exists(self):
		'''画面内に残っているかどうかの確認'''

		return True if self.y <= self.y_max else False


class Paddle:
	def __init__(self, x, y, width, height, x_min, y_min, x_max, y_max):
		'''パドル作成'''

		# 中心座標と半径と移動可能範囲を設定
		self.x = x
		self.y = y
		self.w = width
		self.h = height
		self.x_min = x_min
		self.x_max = x_max
		self.y_min = y_min
		self.y_max = y_max

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.w / 2, self.y - self.h / 2, self.x + self.w / 2, self.y + self.h / 2)

	def move(self, mouse_x, mouse_y):
		'''(mouse_x, mouse_y) に移動'''

		# 移動可能範囲で移動
		self.x = min(max(mouse_x, self.x_min), self.x_max)
		self.y = min(max(mouse_y, self.y_min), self.y_max)


class Block:

	def __init__(self, x, y, width, height):
		'''ブロック作成'''

		# 中心座標とサイズを設定
		self.x = x
		self.y = y
		self.w = width
		self.h = height

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.w / 2, self.y - self.h / 2, self.x + self.w / 2, self.y + self.h / 2)


class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''
		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()
		self.createObjects()
		self.drawFigures()
		self.setEvents()

	def start(self, event):
		'''ゲーム開始'''

		if len(self.blocks) == 0 or len(self.balls) == 0:
			# ゲームクリア or ゲームオーバー時は最初からやり直し

			# キャンバスの図形を全て削除
			self.canvas.delete("all")

			# 全オブジェクトの作り直しと図形描画
			self.createObjects()
			self.drawFigures()

		# ゲーム開始していない場合はゲーム開始
		if not self.is_playing:
			self.is_playing = True
			self.loop()
		else:
			self.is_playing = False

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			# ゲーム開始していないなら何もしない
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		# 全ボールを移動する
		for ball in self.balls:
			ball.move()

		# ボールが画面外に出たかどうかをチェック
		delete_balls = []
		for ball in self.balls:
			if not ball.exists():
				# 外に出たボールは削除対象リストに入れる
				delete_balls.append(ball)

		for ball in delete_balls:
			# 削除対象リストのボールを削除
			self.delete(ball)

		self.collision()
		self.updateFigures()
		self.result()

	def motion(self, event):
		'''パドルの移動'''

		self.paddle.move(event.x, event.y)

	def delete(self, target):
		'''targetのオブジェクトと図形を削除'''

		# 図形IDを取得してキャンバスから削除
		figure = self.figs.pop(target)
		self.canvas.delete(figure)

		# targetを管理リストから削除
		if isinstance(target, Ball):
			self.balls.remove(target)
		elif isinstance(target, Block):
			self.blocks.remove(target)

	def collision(self):
		'''当たり判定と当たった時の処理'''

		for ball in self.balls:

			collided_block = None  # 一番大きく当たったブロック
			max_area = 0  # 一番大きな当たった領域

			for block in self.blocks:

				# ballとblockとの当たった領域の座標を取得
				collision_rect = ball.getCollisionCoords(block)
				if collision_rect is not None:
					# 当たった場合

					# 当たった領域の面積を計算
					x1, y1, x2, y2 = collision_rect
					area = (x2 - x1) * (y2 - y1)

					# 一番大きく当たっているかどうかを判断
					if area > max_area:
						# 一番大きく当たった領域の座標を覚えておく
						max_area = area

						# 一番大きく当たったブロックを覚えておく
						collided_block = block

			if collided_block is not None:

				# 一番大きく当たったブロックに対してボールを反射
				ball.reflect(collided_block)

				# 一番大きく当たったブロックを削除
				self.delete(collided_block)

			for another_ball in self.balls:
				if another_ball is ball:
					# 同じボールの場合はスキップ
					continue

				# ballとanother_ballとの当たり判定
				if ball.getCollisionCoords(another_ball) is not None:

					# 当たってたらballを反射
					ball.reflect(another_ball)

			# ballとself.paddleとの当たり判定
			if ball.getCollisionCoords(self.paddle) is not None:

				# 当たってたらballを反射
				ball.reflect(self.paddle)

	def result(self):
		'''ゲームの結果を表示する'''

		if len(self.blocks) == 0:
			self.canvas.create_text(
				self.width // 2, self.height // 2,
				text="GAME CLEAR",
				font=("", 40),
				fill="blue"
			)

			self.is_playing = False

		if len(self.balls) == 0:
			self.canvas.create_text(
				self.width // 2, self.height // 2,
				text="GAME OVER",
				font=("", 40),
				fill="red"
			)

			self.is_playing = False

	def setEvents(self):
		'''イベント受付設定'''

		self.canvas.bind("<ButtonPress>", self.start)
		self.canvas.bind("<Motion>", self.motion)

	def createWidgets(self):
		'''必要なウィジェットを作成'''

		# キャンバスを作成
		self.canvas = tkinter.Canvas(
			self.master,
			width=self.width,
			height=self.height,
			highlightthickness=0,
			bg="gray"
		)
		self.canvas.pack(padx=10, pady=10)

	def createObjects(self):
		'''ゲームに登場するオブジェクトを作成'''

		# ボールを作成
		self.balls = []
		for i in range(NUM_BALL):
			x = self.width / NUM_BALL * i + self.width / NUM_BALL / 2
			ball = Ball(
				x, self.height // 2,
				RADIUS_BALL,
				RADIUS_BALL, RADIUS_BALL,
				self.width - RADIUS_BALL, self.height - RADIUS_BALL
			)
			self.balls.append(ball)

		# パドルを作成
		self.paddle = Paddle(
			self.width // 2, self.height - Y_PADDLE,
			WIDTH_PADDLE, HEIGHT_PADDLE,
			WIDTH_PADDLE // 2, self.height - Y_PADDLE,
			self.width - WIDTH_PADDLE // 2, self.height - Y_PADDLE
		)

		# ブロックを作成
		self.blocks = []
		for v in range(NUM_V_BLOCK):
			for h in range(NUM_H_BLOCK):
				block = Block(
					h * WIDTH_BLOCK + WIDTH_BLOCK // 2,
					v * HEIGHT_BLOCK + HEIGHT_BLOCK // 2,
					WIDTH_BLOCK,
					HEIGHT_BLOCK
				)
				self.blocks.append(block)

	def drawFigures(self):
		'''各オブジェクト毎に図形を描画'''

		# オブジェクト図形を関連づける辞書
		self.figs = {}

		# ボールを描画
		for ball in self.balls:
			x1, y1, x2, y2 = ball.getCoords()
			figure = self.canvas.create_oval(
				x1, y1, x2, y2,
				fill=COLOR_BALL
			)
			self.figs[ball] = figure

		# パドルを描画
		x1, y1, x2, y2 = self.paddle.getCoords()
		figure = self.canvas.create_rectangle(
			x1, y1, x2, y2,
			fill=COLOR_PADDLE
		)
		self.figs[self.paddle] = figure

		# ブロックを描画
		for block in self.blocks:
			x1, y1, x2, y2 = block.getCoords()
			figure = self.canvas.create_rectangle(
				x1, y1, x2, y2,
				fill=COLOR_BLOCK
			)
			self.figs[block] = figure

	def updateFigures(self):
		'''新しい座標に図形を移動'''

		# ボールの座標を変更
		for ball in self.balls:
			x1, y1, x2, y2 = ball.getCoords()
			figure = self.figs[ball]
			self.canvas.coords(figure, x1, y1, x2, y2)

		# パドルの座標を変更
		x1, y1, x2, y2 = self.paddle.getCoords()
		figure = self.figs[self.paddle]
		self.canvas.coords(figure, x1, y1, x2, y2)


app = tkinter.Tk()
Breakout(app)
app.mainloop()

スポンサーリンク

ブロック崩しゲームの作り方

それでは、ブロック崩しゲームの作り方について解説していきます。

大きく分けると、ブロック崩しゲームを作るために下記の13個のことを行なっていきます。結構解説内容の分量も多いですので、休みを入れながらゆっくり読んでいっていただければと思います。

最終的に出来上がるスクリプトは ブロック崩しゲームのスクリプト と全く同じになりますので、ブロック崩しゲームのスクリプト のスクリプトで何をやっているか分からないところや気になるところだけ読んでも良いですし、もちろん最初から読んでいただいても問題ないです!

解説については、スクリプトを変更しながら、その変更内容について補足していく形で行なっていきたいと思います。スクリプトの変更部分は太字で示していますが、違いが分かりにくい可能性もありますのでご注意ください。

各種パラメータを定義する

では、ブロック崩しゲームの作り方の解説を行なっていきます。

まずはブロック崩しゲームの画面等を作成するために必要になるパラメータをグローバル変数として設定していきたいと思います。

具体的には、スクリプトを下記のように作成します。

各種パラメータの定義
NUM_H_BLOCK = 10  # ブロッックの数(横方向)
NUM_V_BLOCK = 10  # ブロックの数(縦方向)
WIDTH_BLOCK = 40  # ブロックの幅
HEIGHT_BLOCK = 20  # ブロックの高さ
COLOR_BLOCK = "blue"  # ブロックの色

HEIGHT_SPACE = 300  # 縦方向の空きスペース

WIDTH_PADDLE = 200  # パドルの幅
HEIGHT_PADDLE = 20  # パドルの高さ
Y_PADDLE = 50  # パドルの下方向からの位置
COLOR_PADDLE = "green"  # パドルの色

RADIUS_BALL = 10  # ボールの半径
COLOR_BALL = "red"  # ボールの色
NUM_BALL = 2  # ボールの数

UPDATE_TIME = 20  # 更新間隔(ms)

各種パラメータの意味合いはコメントにも書いていますが、図で各パラメータの意味合いを示すと下の図のようになります。

各種パラメータの意味合いを示す図

UPDATE_TIME に関しては、ボールの移動や各種オブジェクトの位置に応じて画面を更新する時間間隔となります(単位は ms です)。

現状では、先程のスクリプトにおいて定義したパラメータは単なる数値ですが、上の図のような意味合いになるように、ここからスクリプトを作成していくことになります。

ウィジェットを作成する

続いてウィジェットを作成していきます。

今回使用するウィジェットは「メインウィンドウ」と「キャンバス」のみとなります。

このメインウィンドウとキャンバスの作成およびメインループの実行を行うために、スクリプトを下記のように変更します(太字部分が変更部分になります)。

ウィジェットの作成とメインループ
import tkinter

NUM_H_BLOCK = 10  # ブロッックの数(横方向)
NUM_V_BLOCK = 10  # ブロックの数(縦方向)
WIDTH_BLOCK = 40  # ブロックの幅
HEIGHT_BLOCK = 20  # ブロックの高さ
COLOR_BLOCK = "blue"  # ブロックの色

HEIGHT_SPACE = 300  # 縦方向の空きスペース

WIDTH_PADDLE = 200  # パドルの幅
HEIGHT_PADDLE = 20  # パドルの高さ
Y_PADDLE = 50  # パドルの下方向からの位置
COLOR_PADDLE = "green"  # パドルの色

RADIUS_BALL = 10  # ボールの半径
COLOR_BALL = "red"  # ボールの色
NUM_BALL = 2  # ボールの数

UPDATE_TIME = 20  # 更新間隔(ms)

class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''
		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()

	def createWidgets(self):
		'''必要なウィジェットを作成'''

		# キャンバスを作成
		self.canvas = tkinter.Canvas(
			self.master,
			width=self.width,
			height=self.height,
			highlightthickness=0,
			bg="gray"
		)
		self.canvas.pack(padx=10, pady=10)

app = tkinter.Tk()
Breakout(app)
app.mainloop()

ここから tkinter を利用していきますので、import tkinter を実行するのを忘れないよう注意してください。

今回はブロック崩しゲームの全体を制御するクラスとして Breakout を用意しています。

メインウィンドウを tkinter.Tk() により作成し、そのメインウィンドウオブジェクトを Breakout クラスに渡し、さらに Breakout クラスの createWidgets メソッドで tkinter.Canvas() によりメインウィンドウ上にキャンバスを作成しています。

また、上記では Breakout クラスのデータ属性を4つ用意しており、それぞれの意味合いは下記のようになります。

  • master:ブロック崩しゲームの作成先となる親ウィジェット
  • width:キャンバスの幅
  • height:キャンバスの高さ
  • is_playing:ゲーム開始しているかどうかを示すフラグ

ウィジェットを作成する上で必須なのは上側の3つのみですが、後から必要になるデータ属性も一緒に追加しています。

master は今回の場合はメインウィンドウとなります。これを tkinter.Canvas() の第1引数に指定することで、メインウィンドウ上にキャンバスを作成することができます。

また、widthheight に関しては、ブロックの個数やブロックのサイズ、さらに空きスペースの高さから計算を行なっており、これらの値を tkinter.Canvas() 実行時にキャンバスのサイズとして指定するようにしています。

計算時に用いている各変数の意味合いについては 各種パラメータを定義する を参照していただければと思います。

さらに、is_playing はゲーム開始中かどうかを示すフラグになります。

このフラグを利用することで、マウスがクリックされた際にゲームを開始したり、ゲーム開始後に再度クリックされた場合にゲームを停止したりするような処理を実現していきます(この is_playing に関しては、後述の 定期的な図形の座標変更を行う から使用することになります)。

上記の変更により、スクリプトを実行すればアプリのメインウィンドウが表示され、その中に色が "gray" のキャンバスが表示されるようになります。さらに、メインループが実行されアプリが待機状態となります。

ウィジェットが作成された様子

ウィジェット作成に関する解説は以上となりますが、メインウィンドウに関しては下記ページで、

メインウィンドウ作成解説ページのアイキャッチTkinterの使い方:メインウィンドウを作成する

キャンバスに関しては下記ページで、

tkinterのキャンバスの作り方解説ページアイキャッチTkinterの使い方:キャンバスウィジェットの作り方

メインループに関しては下記ページでそれぞれ解説しておりますので、必要に応じてご参照いただければと思います。

tkinterのmainloopの解説ページのアイキャッチ【Python】tkinterのmainloopについて解説

スポンサーリンク

各種クラスを作成する

続いて、ボールとパドルとブロックを表現するためのクラスを作成していきたいと思います。

具体的には、下記のようにスクリプトを変更します。変更部分は太字で示しており、要は math モジュールの importBallPaddleBlock のクラスの追加のみを行なっています。

これでクラスの大枠は全て揃ったことになりますので、以降でスクリプトを変更する際は、変更を行うメソッド部分のみを示すようにしていきたいと思います(変更部分はこれまで通り、太字で示していきます)。

クラスの作成
import tkinter
import math

NUM_H_BLOCK = 10  # ブロッックの数(横方向)
NUM_V_BLOCK = 10  # ブロックの数(縦方向)
WIDTH_BLOCK = 40  # ブロックの幅
HEIGHT_BLOCK = 20  # ブロックの高さ
COLOR_BLOCK = "blue"  # ブロックの色

HEIGHT_SPACE = 300  # 縦方向の空きスペース

WIDTH_PADDLE = 200  # パドルの幅
HEIGHT_PADDLE = 20  # パドルの高さ
Y_PADDLE = 50  # パドルの下方向からの位置
COLOR_PADDLE = "green"  # パドルの色

RADIUS_BALL = 10  # ボールの半径
COLOR_BALL = "red"  # ボールの色
NUM_BALL = 2  # ボールの数

UPDATE_TIME = 20  # 更新間隔(ms)


class Ball:
	def __init__(self, x, y, radius, x_min, y_min, x_max, y_max):
		'''ボール作成'''

		# 位置と半径と移動可能範囲を設定
		self.x = x
		self.y = y
		self.r = radius
		self.x_min = x_min
		self.x_max = x_max
		self.y_min = y_min
		self.y_max = y_max

		# 一度に移動する距離(px)
		self.speed = 10

		# 移動方向を設定
		self.angle = math.radians(30)
		self.dx = self.speed * math.cos(self.angle)
		self.dy = self.speed * math.sin(self.angle)

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.r, self.y - self.r, self.x + self.r, self.y + self.r)


class Paddle:
	def __init__(self, x, y, width, height, x_min, y_min, x_max, y_max):
		'''パドル作成'''

		# 中心座標と半径と移動可能範囲を設定
		self.x = x
		self.y = y
		self.w = width
		self.h = height
		self.x_min = x_min
		self.x_max = x_max
		self.y_min = y_min
		self.y_max = y_max

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.w / 2, self.y - self.h / 2, self.x + self.w / 2, self.y + self.h / 2)


class Block:

	def __init__(self, x, y, width, height):
		'''ブロック作成'''

		# 中心座標とサイズを設定
		self.x = x
		self.y = y
		self.w = width
		self.h = height

	def getCoords(self):
		'''左上の座標と右下の座標の取得'''

		return (self.x - self.w / 2, self.y - self.h / 2, self.x + self.w / 2, self.y + self.h / 2)


class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''

		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()

	def createWidgets(self):
		'''必要なウィジェットを作成'''

		# キャンバスを作成
		self.canvas = tkinter.Canvas(
			self.master,
			width=self.width,
			height=self.height,
			highlightthickness=0,
			bg="gray"
		)
		self.canvas.pack(padx=10, pady=10)


app = tkinter.Tk()
Breakout(app)
app.mainloop()

上記の BallPaddleBlock の各クラスでは、メソッドに関してはとりあえず表示に必要なもののみを用意しています。データ属性に関しては移動等を行うために必要なものも用意しています。

__init__

各種クラスの __init__ では下記の引数を受け取り、これらをデータ属性に設定するようにしています(単位は全てピクセルとなります)。

  • Ball
    • xy:ボールの初期位置の中心座標
    • radius:ボールの半径
    • x_minx_maxy_miny_max:ボールの移動可能範囲
  • Paddle
    • xy:パドルの初期位置の中心座標
    • widthheight:パドルのサイズ
    • x_minx_maxy_miny_max:パドルの移動可能範囲
  • Block
    • xy:ブロックの位置の中心座標(ブロックは移動しない)
    • widthheight:ブロックのサイズ

特に Ball に関しては、後述の ボールを移動できるようにする で、x_minx_maxy_miny_max で示す移動可能範囲を「壁の位置」として捉えてボールの反射を実現していきます(移動可能範囲からはみ出たボールを反射させる)。

getCoords

また、getCoords メソッドは各種オブジェクトの左上座標と右下座標を返却するメソッドになります。

getCoordsメソッドの返却値の説明図

キャンバスで図形を描画する際には、これらの左上座標と右下座標が必要になるため、簡単にこれらの座標が取得できるように、この getCoordsメソッドを用意しています。

また、この getCoordsメソッドは当たり判定を行う際にも利用します。

移動量の設定

さらに、ボールは定期的かつ自動的に移動するので、Ball クラスでは一度に移動する量をデータ属性 dxdy で保持するようにしています。dx が横方向の移動量(ピクセル数)、dy が縦方向の移動量(ピクセル数)となります。

後述の ボールを移動できるようにする でボールを定期的に移動するようにしていきますが、この移動はボールの中心座標を表す xy に、これらの dxdy は足すことで実現していきます。

移動前後のボールの中心座標

さらに、これらの dxdy はボールの移動方向(角度)を表す angle と、移動する際の距離 speed から算出するようにしています。この算出は下記で行っています。

dxとdyの算出
self.angle = math.radians(30)
self.dx = self.speed * math.cos(self.angle)
self.dy = self.speed * math.sin(self.angle)

ちょっと難しそうですが、anglespeed、さらに dxdy の関係を図に表すと下記のようになります(キャンバスでは縦方向の正方向が下方向である点に注意)。

dxとdyを求める様子を図で示したもの

このように、cos 関数や sin 関数を利用することにより、角度と長さ(anglespeed)から横軸の長さや縦軸の長さを求めることができます。

この方法で dxdy を求めるメリットは「角度に関わらず移動する距離が一定(その距離は speed)」になる点です。ですので、どの方向に移動する際にもボールが同じ速度で移動させられることができます。

座標を扱うようなゲームやアプリを作成する際には結構使えるテクニックですので、上記のような式は覚えておいて損はないです!

また、cos 関数や sin 関数の引数には角度を「ラジアン」単位で指定する必要があります。「度」から「ラジアン」への単位変換は math.radians で行えますので、これも一緒に覚えておくと良いと思います。

で、上記のように dxdy を求めることができますので、ボールが壁や他のオブジェクトにぶつかった際に angle を変更して再度 dxdy を求め直せば、今度は他の方向にボールを移動させていくことができます。この角度変更については ボールを移動できるようにする で実装していきます。

MEMO

ちなみにパドルはマウスカーソルの位置に合わせて移動するようにするため、上記のような自動で移動する際の移動量を示すデータ属性は不要になります

各種オブジェクトを作成する

とりあえず表示するためのメソッドは用意しましたので、次はボール・パドル・ブロックの各種オブジェクトを表示するようにしていきたいと思います。

まずは各種オブジェクトの作成を行います。

このオブジェクトの作成を行うために、Breakout クラスに下記の createObjects メソッドを追加します。

createObjectsの実行
class Breakout:

	def createObjects(self):
		'''ゲームに登場するオブジェクトを作成'''

		# ボールを作成
		self.balls = []
		for i in range(NUM_BALL):
			x = self.width / NUM_BALL * i + self.width / NUM_BALL / 2
			ball = Ball(
				x, self.height // 2,
				RADIUS_BALL,
				RADIUS_BALL, RADIUS_BALL,
				self.width - RADIUS_BALL, self.height - RADIUS_BALL
			)
			self.balls.append(ball)

		# パドルを作成
		self.paddle = Paddle(
			self.width // 2, self.height - Y_PADDLE,
			WIDTH_PADDLE, HEIGHT_PADDLE,
			WIDTH_PADDLE // 2, self.height - Y_PADDLE,
			self.width - WIDTH_PADDLE // 2, self.height - Y_PADDLE
		)

		# ブロックを作成
		self.blocks = []
		for v in range(NUM_V_BLOCK):
			for h in range(NUM_H_BLOCK):
				block = Block(
					h * WIDTH_BLOCK + WIDTH_BLOCK // 2,
					v * HEIGHT_BLOCK + HEIGHT_BLOCK // 2,
					WIDTH_BLOCK,
					HEIGHT_BLOCK
				)
				self.blocks.append(block)

さらに、Breakout クラスの __init__ を下記のように変更し、アプリ起動時に createObjects メソッドを実行するようにします。

createObjectsの実行
class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''

		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()
		self.createObjects()

createObjects() メソッドについて簡単に説明しておくと、簡単に言ってしまえば各クラスのコンストラクタ実行によるオブジェクトの作成、および、そのオブジェクトのデータ属性のリストへの格納を行っています。

Ball クラスと Block クラスのオブジェクトに関しては複数作成しますので、データ属性にはリストを用意し、そのリストに格納していくことでオブジェクトをデータ属性で管理できるようにしています(BallballsBlockblocks で管理)。

各クラスのコンストラクタに指定する引数が若干複雑ですが、指定する引数の意味は 各種クラスを作成する で追加した各クラスの __init__ を参照していただければ理解できるのではないかと思います。

まだオブジェクトを生成するだけなので実感はないと思いますが、これらのコンストラクタに指定する引数に応じて各オブジェクトの初期位置や表示する際のサイズが決定されます(さらにはボールやパドルに関しては移動可能な範囲も)。

例えば Ball() 実行時の引数の意味合いを図示すると下の図のようになります。

Ball()実行時の引数の意味合いを示す図

特に Block に関しては横方向と縦方向に並べる感じで配置していく必要があるため、第1引数と第2引数はお互いが重ならないように調整しながら指定する必要があります。

また Paddle に関しては、縦方向には移動しないことを前提としていますので、y_miny_max 両方に self.height - Y_PADDLE を指定しています。逆にこれらに異なる値を設定すれば、パドルも縦方向に移動するようになります。

各種図形を描画する

オブジェクトの生成ができたので、次はそのオブジェクトを図形としてキャンバスに描画して画面に表示していきたいと思います。

ゲーム起動時の図形の描画

まず、ゲーム起動時(スクリプト実行直後)に各種オブジェクトを図形として描画するようにしていきます。

このためには、まず Breakout クラスに下記の drawFigures メソッドを追加します。

drawFigures
class Breakout:

	def drawFigures(self):
		'''各オブジェクト毎に図形を描画'''

		# オブジェクト図形を関連づける辞書
		self.figs = {}

		# ボールを描画
		for ball in self.balls:
			x1, y1, x2, y2 = ball.getCoords()
			figure = self.canvas.create_oval(
				x1, y1, x2, y2,
				fill=COLOR_BALL
			)
			self.figs[ball] = figure

		# パドルを描画
		x1, y1, x2, y2 = self.paddle.getCoords()
		figure = self.canvas.create_rectangle(
			x1, y1, x2, y2,
			fill=COLOR_PADDLE
		)
		self.figs[self.paddle] = figure

		# ブロックを描画
		for block in self.blocks:
			x1, y1, x2, y2 = block.getCoords()
			figure = self.canvas.create_rectangle(
				x1, y1, x2, y2,
				fill=COLOR_BLOCK
			)
			self.figs[block] = figure

さらに上記の drawFigures メソッドがゲーム起動時に実行されるよう、Breakout クラスの __init__ を下記のように変更します。

drawFiguresの実行
class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''

		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()
		self.createObjects()
		self.drawFigures()

以上の変更を行うことで、createObjects メソッドで作成したオブジェクトが全て、図形としてキャンバスに描画されるようになります(意図していませんでしたが顔っぽく見えますね…)。

各種オブジェクトが図形として描画された様子

簡単に drawFigures メソッドで行っている処理について説明しておきます。

まずボールは「円」、さらにパドルとブロックは「長方形」としてキャンバスに描画を行うようにしています。

そのため、ボールを図形として描画する際には、create_oval メソッドを、パドルとブロックを描画する際には create_rectangle メソッドを実行するようにしています。

描画時に使用するメソッドの説明図

これらのキャンバスに図形を描画するメソッドに関しては下記ページで解説していますので、描画可能な図形の種類や各オプションの意味合い等を知りたい方はこちらを参照していただければと思います。

tkinterキャンバスに図形を描画する方法解説ページのアイキャッチTkinterの使い方:Canvasクラスで図形を描画する

各種オブジェクトを作成する で作成した createObjects メソッドでは、各種オブジェクトの生成時に初期位置やサイズの設定を行いました。

さらに各種オブジェクトは Breakout クラスの下記のデータ属性により管理されるようになっています。

  • ボール:balls(リスト)
  • パドル:paddle
  • ブロック:blocks(リスト)

また、上記ページでも解説していますが、create_oval メソッドと create_rectangle メソッド共に、第1引数と第2引数には描画する図形の左上座標を、第3引数と第4引数には描画する図形の右下座標を指定する必要があります。

これらは BallPaddleBlock クラスそれぞれに用意した getCoords メソッドにより取得することが可能です。

したがって、ボール等のオブジェクトを図形として描画するためには、これらのオブジェクトに getCoords メソッドを実行させて必要な座標を取得し、さらに create_oval メソッドや create_rectangle メソッドの第1引数から第4引数には getCoords メソッドで取得した座標をそのまま指定して実行してやれば良いことになります。

で、この処理を管理しているオブジェクト全てに対して行なっているのが drawFigures メソッドとなります。

図形 ID の辞書での記憶

もう一点付け加えておくと、drawFigures メソッドでは create_oval メソッドや create_rectangle メソッドの返却値である図形 ID と描画したオブジェクトとを関連づけて辞書 figs に保存するようにしています。

図形の削除や移動を行う際には図形 ID が必要になりますので、このように辞書でオブジェクトから図形 ID を取得できるようにしておくと便利です。

例えば、ボールと当たったブロックのオブジェクト block をキャンバスから削除する場合、辞書 figs から下記のように図形 ID を取得して図形の削除を行うことが可能です。

blockの図形の削除
# 図形IDを取得してキャンバスから削除
figure = self.figs[block]

self.canvas.delete(figure)

スポンサーリンク

定期的な図形の座標変更を行う

続いて、描画した図形の座標を定期的に更新していくようにしていきたいと思います。

図形の座標変更

先程の drawFigures の実行により、ゲーム起動時にボールやパドル、ブロックがキャンバスに描画されて画面上に表示されるようになりました。

ただ、現状では図形の描画が起動時に一回実行されるだけなので、ボールやパドルの座標が変化したとしても画面が変わりません。

次は、定期的にボールやパドルの座標を変化させるようにしていきたいと思います。

これを行うために、まず下記の updateFigures メソッドを Breakout クラスに追加します。

updateFigures
class Breakout:

	def updateFigures(self):
		'''新しい座標に図形を移動'''

		# ボールの座標を変更
		for ball in self.balls:
			x1, y1, x2, y2 = ball.getCoords()
			figure = self.figs[ball]
			self.canvas.coords(figure, x1, y1, x2, y2)

		# パドルの座標を変更
		x1, y1, x2, y2 = self.paddle.getCoords()
		figure = self.figs[self.paddle]
		self.canvas.coords(figure, x1, y1, x2, y2)

この updateFigures メソッドでは、ボールとパドルそれぞれのオブジェクトに対して現在の座標と図形 ID を取得し、さらにそれらの情報を引数としてキャンバスの coords メソッドを実行しています。

coords メソッドは、第1引数で指定した図形 ID に対応する図形の座標を第2引数以降で指定した座標に変更するメソッドになります。

coordsメソッドの動作の説明図

したがって、ボールやパドルのオブジェクトの位置(データ属性の xy)が変化した後に上記 updateFigures が実行されれば、そのオブジェクトの位置に応じて図形の位置も移動することになります。

一点補足しておくと、上の図の通り coords メソッドは図形のサイズも変更可能ですが、今回は位置のみの変更を行います。位置の変更のみであれば coords メソッドではなく、move メソッドや moveto メソッドでも実現する事が可能です。

これらの描画済みの図形を操作するメソッドについては下記ページでまとめていますので、詳しく知りたい方は下記ページを参照していただければと思います。

tkinterキャンバスの図形の操作方法解説ページのアイキャッチTkinterの使い方:Canvasクラスで描画した図形を操作する

定期的な図形の座標変更の実行

続いて、先ほど作成した updateFigures が定期的に実行されるようにしていきたいと思います。そのために、下記の loop メソッドを Breakout クラスに追加します。

loop
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		self.updateFigures()

loop メソッドでは after メソッドを実行しており、これにより UPDATE_TIME 間隔で loop が繰り返し実行されることになります。そしてこの loop メソッドの中で updateFigures を実行しているため、ほぼ UPDATE_TIME 間隔で各オブジェクトの図形の座標変更が繰り返し行われることになります。

したがって、オブジェクトの座標(データ属性 xy)が変化すれば、その後に実行される updateFigures メソッドにより自動的に図形の座標(位置)も変化するようになります。

この after メソッドについては下記ページで解説していますので、詳しく知りたい方はこちらをご参照いただければと思います。

Tkinterの使い方:after で処理を「遅らせて」or 処理を「定期的」に実行する

定期的な処理の開始

先ほど追加した loop メソッドは、前述の通り定期的に実行されるメソッドになります。そのため、ブロック崩しゲームにおいて定期的に必要になる処理はこの loop メソッド内で行わせるようにしていこうと思います。例えばボールの移動も、この定期的に必要になる処理となります。

したがって、この loop メソッドが実行された際には、ボールが移動を始め、ブロック崩しゲームが開始されることになります(ボールの移動自体は後から実装します)。

ですので、Breakout クラスの __init__ からこの loop メソッドを実行した場合、ゲーム起動時にすぐにゲームが始まることになります。ただそれだと急すぎてユーザーがびっくりするかなぁと思いますので、今回はマウスのボタンがクリックされた時にゲームが始まるようにしたいと思います。

すなわち、マウスのボタンがクリックされた際に loop メソッドを実行するようにします。

このために、まず下記の setEvents メソッドを Breakout クラスに追加します。

setEvents
class Breakout:

	def setEvents(self):
		'''イベント受付設定'''

		self.canvas.bind("<ButtonPress>", self.start)

これにより、setEvents メソッド実行以降は、マウスのボタンがクリックされた時に Breakout クラスの start メソッドが実行されるようになります。

ここで使用している bind メソッドは下記ページで解説していますので、詳しく知りたい方は下記ページをご参照ください。

イベント処理解説ページのアイキャッチTkinterの使い方:イベント処理を行う

さらに、この setEvents メソッドを実行するように Breakout クラスの __init__ を下記のように変更します。

setEventsの実行
class Breakout:

	def __init__(self, master):
		'''ブロック崩しゲーム起動'''

		self.master = master

		# サイズを設定
		self.width = NUM_H_BLOCK * WIDTH_BLOCK
		self.height = NUM_V_BLOCK * HEIGHT_BLOCK + HEIGHT_SPACE

		# ゲーム開始フラグを設定
		self.is_playing = False

		self.createWidgets()
		self.createObjects()
		self.drawFigures()
		self.setEvents()

これでゲーム起動後にマウスクリックを実行すれば Breakout クラスの start メソッドが実行されるようになったことになります。

ただ、まだ肝心の start メソッドを作成していませんので、ここで下記の start メソッドを Breakout クラスに追加します。

start
class Breakout:

	def start(self, event):
		'''ゲーム開始'''

		# ゲーム開始していない場合はゲーム開始
		if not self.is_playing:
			self.is_playing = True
			self.loop()
		else:
			self.is_playing = False

この start メソッドで使用しているデータ属性 is_playing は ウィジェットを作成する で追加した「ゲーム開始したかどうか」を判断するためのフラグになります。初期値は False です。

したがって、ゲーム起動後のマウスクリックにより上記の start メソッドが実行された際には、is_playingTrue にした後に loop メソッドが実行され、ゲームが開始されることになります(定期的な図形の座標変更を開始する)。

また上記 start メソッドでは、is_playingTrue  の場合に False に設定し直す処理も行っています。これを利用すれば、ゲームを一時停止するようなことも可能です。

具体的には、Breakout クラスの loop メソッドを下記のように変更すれば、ゲームを一時停止することができるようになります。

ゲームの停止
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			# ゲーム開始していないなら何もしない
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		self.updateFigures()

上記の変更により、is_playingFalse の場合には、after メソッドが実行されないようになります。したがって、それ以降は loop メソッドの定期的な実行が行われなくなります。

ただし、loop メソッドの定期的な実行が停止している間でもマウスクリックにより start メソッドが実行されますので、これにより再びゲームを再開することができます(is_playingFalse の時に start メソッドが実行されれば、is_playing  が True に設定された後に loop メソッドが実行される)。

要はマウスのクリックでゲーム開始とゲームの停止、ゲームの再開を行えるようになったことになります。

ただ、現状だとボールやパドルが移動しないため、ここまでの変更を行なったスクリプトを実行しても何も動作せず、定期的に図形の描画が行われていることや、ゲームの開始や停止等が行えるようになったことが実感できません。

早くこれらを実感したいという方は、Breakout クラスの updateFigures メソッドを下記のように変更してみてください。

ボールの移動のお試し
class Breakout:

	def updateFigures(self):
		'''新しい座標に図形を移動'''

		# ボールの座標を変更
		for ball in self.balls:
			ball.x += 1
			ball.y += 1
			x1, y1, x2, y2 = ball.getCoords()
			figure = self.figs[ball]
			self.canvas.coords(figure, x1, y1, x2, y2)

		# パドルの座標を変更
		x1, y1, x2, y2 = self.paddle.getCoords()
		figure = self.figs[self.paddle]
		self.canvas.coords(figure, x1, y1, x2, y2)

スクリプト実行後にマウスをクリックすれば、ボールが右下方向に移動し始めることが確認できると思います。また、ボールが移動している最中にマウスをクリックすればボールの移動が停止すること、さらに再度クリックすれば、ボールが再び動き出すことが確認できると思います。

これらが確認できれば、ここまでのスクリプトの変更をうまく反映できており、ブロック崩しゲームを作成していく上での基盤が出来上がったことになります。

ただし、上記の変更はあくまでも「お試し」の変更ですので、確認ができれば追加した2行は削除しておいてください。次の ボールを移動できるようにする で正式にボールの移動を実現していきます。

ボールを移動できるようにする

ここまでは単にボール・パドル・ブロックの表示を行うだけでしたので、あまりゲームっぽさがなかったと思います。ですが、ここからの変更によりオブジェクトに動きが出てきてゲーム作成っぽさが出てくると思います!

まずは、ボールを移動するようにしていきます。

ボールの移動

まずは反射を考えずに単に一方向にボールを移動させるようにしていきたいと思います。

これを行うために、まず Ball クラスに下記の move メソッドを追加します。

Ballのmove
class Ball:

	def move(self):
		'''移動'''

		# 移動方向に移動
		self.x += self.dx
		self.y += self.dy

さらに、Ball クラスの全オブジェクトに対して上記の move メソッドを実行させるよう、Breakout クラスの loop メソッドを次のように変更します。

Ballのmoveの実行
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		# 全ボールを移動する
		for ball in self.balls:
			ball.move()

		self.updateFigures()

上記のように変更を行ってスクリプトを実行し、さらにマウスをクリックしてゲームを開始すれば、ボールが移動するようになったことを確認できると思います。ただすぐにボールが画面外に消えてしまうとは思いますが…。

ボールが移動する様子

Ball クラスの move メソッドではデータ属性 xy の変更を行なっています。この xy はオブジェクトの存在する位置の中心座標を示すデータ属性であり、この座標に基づいてオブジェクトを図形として表示する座標が決まります。

さらに、上記の loop メソッドは、前述の 定期的な図形の座標変更を行う で追加したメソッドで、after メソッドにより UPDATE_TIME 間隔で実行されます(UPDATE_TIME各種パラメータを定義する で導入したグローバル変数です)。

この loop の中で Ball クラスの move メソッドと updateFigures メソッドが実行されるため、ボールの中心座標の変更と、その中心座標に基づいた図形の座標の変更が定期的に行われることになります。そのため、ボールの図形が自動的にどんどん移動してことになります。

壁と当たった時の反射

ただし、まだ反射を行なっていないため、ボールが常に一方向にしか移動せず、すぐに画面外に出てしまって見えなくなってしまいます。

これを防ぐため、次はボールの反射を実現していきたいと思います。

これを実現するため、まずは Ball クラスに下記の turn メソッド・reflectH メソッド・reflectV メソッドを追加します。

ボールの反射
class Ball:

	def turn(self, angle):
		'''移動方向をangleに応じて設定'''

		self.angle = angle
		self.dx = self.speed * math.cos(self.angle)
		self.dy = self.speed * math.sin(self.angle)
	def reflectH(self):
		'''横方向に対して反射'''

		self.turn(math.atan2(self.dy, -self.dx))
	def reflectV(self):
		'''縦方向に対して反射'''

		self.turn(math.atan2(-self.dy, self.dx))

さらに、前述で追加した Ball クラスの move メソッドを下記のように変更します。

反射を考慮したボールの移動
class Ball:

	def move(self):
		'''移動'''

		# 移動方向に移動
		self.x += self.dx
		self.y += self.dy

		if self.x < self.x_min:
			# 左の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_min

		elif self.x > self.x_max:
			# 右の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_max

		if self.y < self.y_min:
			# 上の壁とぶつかった

			# 縦方向に反射
			self.reflectV()
			self.y = self.y_min

		elif self.y > self.y_max:
			# 下の壁とぶつかった

			# 縦方向に反射
			self.reflectV()
			self.y = self.y_max

上記のように変更を加えた後にスクリプトを実行してマウスをクリックすれば、ボールが移動し、キャンバスの端まで移動した際に反射するようになったことが確認できると思います(ただ、まだ当たり判定や当たった時の処理を実装していないためブロックやパドルの後ろ側をすり抜けてしまいます)。

ボールが反射する様子

なぜ上記の変更でボールが反射するようになったかを簡単に説明しておきます。

まず turn は移動方向を変化させるメソッドになります。

turnメソッドの動作を示す図

具体的には、引数で指定された angle に応じてデータ属性 dxdy を設定し直すメソッドになります。dxdy に関しては 各種クラスを作成する で紹介した通り、ボールの横方向と縦方向の移動量を示すデータ属性となります。

MEMO

キャンバスでは y 軸の正方向が下方向であり、数学等で扱う座標とは逆なので注意してください

y 軸の正方向が下方向であるため、キャンバスでの角度の正方向は時計回り方向になりますので、この点にも注意してください(数学で扱う角度の正方向は “反” 時計回り)

dxdy の求め方についても 各種クラスを作成する で解説していますので、忘れてしまった方は 各種クラスを作成する を参照していただければと思います。

また、reflectHreflectV はそれぞれ、ボールを横方向に反射させるメソッドとボールを縦方向に反射させるメソッドになります。

reflectHメソッドとreflectVメソッドの動作を示す図

で、このメソッドで実行しているのが先ほど紹介した turn メソッドになります。turn メソッドには math.atan2 関数の返却値を指定しています。

この math.atan2 は引数で指定された座標から角度を求める関数になります。

例えば math.atan2(y, x) を実行すれば、原点と (x, y) 座標を結ぶ直線が横軸と正方向に成す角の角度を求めることができます。

atan2関数の意味合いを示す図

したがって、math.atan2(dy, -dx) を実行した場合、math.atan2(dy, dx) に比べて横方向に反射させた場合の角度を求めることができます。

atan2関数から横方向に反射した角度を取得する様子

データ属性 dxdy は現在の移動方向に対する移動量になりますので、math.atan2(dy, -dx) を実行すれば、現在の移動方向から横方向に反射した時の移動方向(角度)を算出することができることになります。

さらに math.atan2(dy, -dx) の結果に対して turn メソッドを実行すれば、反射後の移動方向に応じて dxdy が再設定されることになり、それ以降は反射後の移動方向にボールを移動出せていくことができます。

atan2から求めた角度に対してturnメソッドを実行して横方向に反射させる様子

上記の考え方で横方向の反射を実現するために、reflectH では引数 math.atan2(dy, -dx) を指定して turn メソッドを実行しています。

reflectV も同様で、math.atan2(-dy, dx) を実行して現在の移動方向から縦方向に反射した時の移動方向(角度)を算出し、

atan2関数から縦方向に反射した角度を取得する様子

さらに math.atan2(-dy, dx) の結果を turn メソッドに指定して実行することで、縦方向の反射を実現しています。

atan2から求めた角度に対してturnメソッドを実行して縦方向に反射させる様子

MEMO

上記で行なっているのは、入射角と反射角が等しくなるような反射になります

この場合、実はわざわざ math.atan2turn メソッドを実行しなくても、横方向に反射させるためには dx = - dx を、縦方向に反射させるためには dy = - dy を実行するだけでも実現可能です

ただ、後述の ブロック崩しゲームのカスタマイズ の 反射角度にランダム性を持たせる で紹介するような、反射角度にランダム性を持たせるなどの “移動方向や反射角度に対する拡張” を行うことを考慮すれば、このように角度から dx や dy を算出するようにしておいた方が便利です

reflectHreflectV を追加したことで、これらのメソッドを実行するだけでボールの反射を実現することができるようになりました。

あとは、移動したボールが壁とぶつかってしまった場合に(移動可能範囲の外に出てしまった場合に)、どの方向の壁とぶつかったのかを考慮して reflectHreflectV を呼び分けるようにしてやれば、ボールの反射を実現することができます。

で、これを行なっているのが上記の move メソッドになります。壁の位置は移動可能範囲としてデータ属性 x_minx_maxy_miny_max に設定されていますので、これらの値とデータ属性 xy との大小関係からどの方向の壁とボールがぶつかったを判断し、それに応じて reflectHreflectV を呼び分けるようにしています。

下側の壁とぶつかった場合、普通はブロック崩しゲームではゲームオーバーになるのですが、とりあえず現状は下の壁とぶつかった場合もボールを反射させるようにしています。現状だとパドルに当たってもすり抜けるので、すぐに下の壁とぶつかってしまいますからね…。

パドルを移動できるようにする

ボールが移動できるようになったので、次はパドルを移動させるようにしていきたいと思います。

パドルはボールと異なり、マウスカーソルの位置に応じて移動するようにしていきます。

パドルの位置の変更

まずはパドルの位置を変更できるように Paddle クラスに下記の move メソッドを追加します。

Paddleのmove
class Paddle:

	def move(self, mouse_x, mouse_y):
		'''(mouse_x, mouse_y) に移動'''

		# 移動可能範囲で移動
		self.x = min(max(mouse_x, self.x_min), self.x_max)
		self.y = min(max(mouse_y, self.y_min), self.y_max)

パドルはボールと違って反射などはしませんので、単純に x_minx_maxy_miny_max から求まる移動範囲内に収まるように mouse_xmouse_y の値を丸め、それを xy に設定するようにしています。

あとは mouse_xmouse_y にマウスカーソルの座標を指定して move が実行されれば、パドルの位置がマウスカーソルの位置に応じて移動することになります(そして移動後に updateFigures が実行されることで、移動後の位置にパドルの図形も移動する)

イベントの受付設定

ということで、次は先程作成した Paddle クラスの move メソッドがマウスカーソル移動時に実行されるようにしていきたいと思います。

マウスのクリック同様に、こういったマウスカーソルの移動に関してもイベントとして扱うことができ、このイベントが発生した時に実行される関数やメソッドを bind メソッドで設定することが可能です。

具体的には、下記のように Breakout クラスの setEvents メソッドを実行すれば、マウスカーソル移動時に Breakout クラスの motion メソッドが実行されるように設定することができます。

マウス移動イベントの受付
class Breakout:

	def setEvents(self):
		'''イベント受付設定'''

		self.canvas.bind("<ButtonPress>", self.start)
		self.canvas.bind("<Motion>", self.motion)

さらに、Breakout クラスに下記の motion メソッドを追加します。

motion
class Breakout:

	def motion(self, event):
		'''パドルの移動'''

		self.paddle.move(event.x, event.y)

bind メソッドで実行されるように設定した関数やメソッドに関しては、実行時にイベントの詳細情報 event が引数として渡されることになります。

この event.xevent.y にはマウスカーソルの座標が格納されていますので、これを Paddle クラスの move メソッドの引数に指定してやれば、マウスカーソルの位置に応じてパドルを移動させることができることになります。

上記のように変更を加えたスクリプトを実行してマウスをクリックしてからマウスカーソルを移動すれば、パドルがマウスカーソルを追う形で横方向に移動することが確認できると思います。

マウスカーソルの移動に伴いパドルが移動する様子

横方向にしか移動しない理由は Paddle クラスのコンストラクタ実行時に y_miny_max に同じ値を設定しているからで、これらの値に異なる値を設定してやれば、パドルを縦方向に移動させることも可能になります。

スポンサーリンク

ボールと当たったブロックを消す

ちょっとずつブロック崩しゲームを作っている感じが出てきたのではないでしょうか?

次は、ボール移動後にボールとブロックとの当たり判定を行い、さらに当たったブロックを消すようにしていきます。

もし、ボール移動後に複数のブロックと当たっていた場合は、今回は「一番大きく当たったブロック」のみ当たったと判定し、そのブロックのみを消すようにしていきたいと思います。これはブロックと当たった時のボールの反射角度を求める処理を簡略化するためです。

ボールと他のオブジェクトとが当たった領域を求める

まずは、ボールが他のオブジェクトと当たったかどうかを判定できるようにしていきたいと思います。

今回は簡単のため、ボールも矩形とみなし、各オブジェクトが当たったかどうかは矩形同士が重なったかどうかで判断を行うようにしていきたいと思います。

全て矩形とみなして当たり判定を行うことを示す図

矩形同士が当たったかどうかを判定するために、まず Ball クラスに下記の getCollisionCoords メソッドを追加します。

getCollisionCoords
class Ball:

	def getCollisionCoords(self, object):
		'''objectと当たった領域の座標の取得'''

		# 各オブジェクトの座標を取得
		ball_x1, ball_y1, ball_x2, ball_y2 = self.getCoords()
		object_x1, object_y1, object_x2, object_y2 = object.getCoords()

		# 新たな矩形の座標を取得
		x1 = max(ball_x1, object_x1)
		y1 = max(ball_y1, object_y1)
		x2 = min(ball_x2, object_x2)
		y2 = min(ball_y2, object_y2)

		if x1 < x2 and y1 < y2:
			# 始点が終点よりも左上にある

			# 当たった領域の左上座標と右上座標を返却
			return (x1, y1, x2, y2)
		else:

			# 当たっていないならNoneを返却
			return None

この getCollisionCoords メソッドは、実行した Ball クラスのオブジェクトと引数の object で指定されるオブジェクトとで当たった領域(重なっている領域)の座標を返却します。もし当たっていない場合は None を返却します。

getCollisionCoordsの動作を示す図

ですので、getCollisionCoords メソッドの返却値が None 以外の場合、2つのオブジェクトは当たったと判定することができます。

また、2つのオブジェクトが当たった場合は「当たった領域の座標」が返却されますので、複数のブロックと当たった時に、どのブロックと一番大きく当たったかを getCollisionCoords メソッドの返却座標から判断をすることができます。

getCollisionCoords メソッドで何をやっているかというと、まず前提として、selfobject が当たっているかどうかは、selfobject それぞれの左上座標と右下座標から下記によって求まる新たな矩形の始点と終点の位置関係から求めることができます(ここからは、getCollisionCoords メソッドを実行したオブジェクトを self として説明させていただきます)。

  • 始点の x 座標:下記の2つの座標の大きい方
    • selfの左上座標の x 座標
    • objectの左上座標の x 座標
  • 始点の y 座標:下記の2つの座標の大きい方
    • selfの左上座標の y 座標
    • objectの左上座標の y 座標
  • 終点の x 座標:下記の2つの座標の小さい方
    • selfの右下座標の x 座標
    • objectの右下座標の x 座標
  • 終点の y 座標:下記の2つの座標の小さい方
    • selfの右下座標の y 座標
    • objectの右下座標の y 座標

上記で求まる矩形の始点と終点において、始点が終点よりも左上側に存在する場合、selfobject は当たったと判定することができます。そして、この時の始点と終点が、selfobject とで当たった領域の左上座標と右下座標になります。

当たっているときと当たっていない時の始点と終点の位置関係

逆に始点が終点よりも左上側に存在しない場合、selfobject は当たっていないと判定することができます。

getCollisionCoords メソッドは上記の考え方に基づいて処理を行なっており、selfobject それぞれの左上座標と右下座標を getCoords メソッドにより取得し、これらの座標から上記で示した方法で新たな矩形の始点(x1, y1)と終点(x2, y2)を求め、さらに、始点が終点よりも左上に存在するかどうかを if x1 < x2 and y1 < y2 が成立するかどうかで判断しています。

成立する場合は、selfobject が当たったとみなして (x1, y1, x2, y2) を返却し、それ以外は None を返却するようにしています。

オブジェクトを消す

Ball クラスの getCollisionCoords メソッドにより、ボールと他のオブジェクトが当たったかどうかを判定するための機能の実装はできましたので、次は当たったブロックを消すための機能を追加していきたいと思います。

このために、Breakout クラスに下記の delete メソッドを追加します。

delete
class Breakout:

	def delete(self, target):
		'''targetのオブジェクトと図形を削除'''

		# 図形IDを取得してキャンバスから削除
		figure = self.figs.pop(target)
		self.canvas.delete(figure)

		# targetを管理リストから削除
		if isinstance(target, Ball):
			self.balls.remove(target)
		elif isinstance(target, Block):
			self.blocks.remove(target)

この delete メソッドでは、主に下記の3つのことを行なっています(figs は 各種図形を描画する で作成した “図形 ID をオブジェクトに関連づけて管理している辞書” です)。

  • figs からの target の削除
  • target に対応する図形の削除
  • オブジェクト管理リストからの target の削除
    • (オブジェクト管理リスト:self.blocks or self.balls

要は、この delete メソッドで target の図形および、辞書やリストで管理していた target の情報全てを削除しています。

ですので、当たったと判定されたブロックのオブジェクトを引数に指定して delete メソッドを実行することで、キャンバスからも削除されますし、以降、そのオブジェクトに対して処理が実行されるようなことは無くなります。

ボールとブロックの当たり判定を実行する

ここまでの変更で、getCollisionCoords メソッドにより当たったかどうかを判定することができるようになり、delete メソッドによりオブジェクトを削除することができるようになりました。

あとはこれらのメソッドを実行するようにして、ボールがブロックと当たった時にブロックを消すようにしていきたいと思います。

このために、まずは Breakout クラスに下記の collision メソッドを追加します。

Breakoutのcollision
class Breakout:

	def collision(self):
		'''当たり判定と当たった時の処理'''

		for ball in self.balls:
			
			collided_block = None # 一番大きく当たったブロック
			max_area = 0 # 一番大きな当たった領域

			for block in self.blocks:

				# ballとblockとの当たった領域の座標を取得
				collision_rect = ball.getCollisionCoords(block)
				if collision_rect is not None:
					# 当たった場合

					# 当たった領域の面積を計算
					x1, y1, x2, y2 = collision_rect
					area = (x2 - x1) * (y2 - y1)

					# 一番大きく当たっているかどうかを判断
					if area > max_area:
						# 一番大きく当たった領域の座標を覚えておく
						max_area = area

						# 一番大きく当たったブロックを覚えておく
						collided_block = block


			if collided_block is not None:

				# 一番大きく当たったブロックを削除
				self.delete(collided_block)

ちょっと長いですが、やってることは、データ属性 balls で保持しているボール1つ1つに対し、self.blocks で保持しているブロックの中で一番大きく当たっているブロックを見つけ出し、その一番大きく当たっているブロックを delete メソッドで削除しているだけです。

で、一番大きく当たっているブロックを見つけ出すためには “ボールとブロックの当たった領域の座標” が必要になるため、そこに前述で作成した Ball クラスの getCollisionCoords メソッドを利用しています。そして、得られた座標より当たった領域の面積が一番大きいブロックを見つけ出すようにしています。

後は、上記で用意した Breakout クラスの collision メソッドを定期的に実行するよう Breakout クラスの loop メソッドを次のように変更すれば、ボールと当たったブロックを消す処理は完成です。

collisionの定期実行
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			# ゲーム開始していないなら何もしない
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		# 全ボールを移動する
		for ball in self.balls:
			ball.move()

		self.collision()
		self.updateFigures()

これにより、ボールが移動した後に当たり判定が行われ、当たった際にはブロックが消えるようになります。

実際にスクリプトを実行してゲームを開始すれば、ボールと当たったブロックがどんどん消えていくことが確認できると思います。

ボールと当たったブロックが消えていく様子

ただ、まだブロックと当たったボールを反射させるようにしていないため、どんどんブロックを突き抜けていく感じになっています。次は、ブロックなどの他のオブジェクトとボールが当たった際に、ボールを反射させるようにしていきます。

他のオブジェクトと当たったボールを反射させる

では、ボールが他のオブジェクトと当たった時にボールを反射させるようにしていきたいと思います。

ブロックと当たったボールを反射させる

先ほど追加した Breakout クラスの collision メソッドにより、既にブロックとボールが当たったかどうかを判定することができるようになっています。ですので、まずはブロックと当たったボールを反射させるようにしていきたいと思います。

このためには、まず Ball クラスに下記の reflect を追加します。

reflect
class Ball:

	def reflect(self, object):
		'''当たった方向に応じて反射'''

		# 各オブジェクトの座標を取得
		object_x1, object_y1, object_x2, object_y2 = object.getCoords()

		# 重なった領域の座標を取得
		x1, y1, x2, y2 = self.getCollisionCoords(object)

		is_collideV = False
		is_collideH = False

		# どの方向からボールが当たったかを判断
		if self.dx < 0:
			# ボールが左方向に移動中
			if x2 == object_x2:
				# objectの左側と当たった
				is_collideH = True
		else:
			# ボールが右方向に移動中
			if x1 == object_x1:
				# objectの右側と当たった
				is_collideH = True

		if self.dy < 0:
			# ボールが上方向に移動中
			if y2 == object_y2:
				# objectの下側と当たった
				is_collideV = True
		else:
			# ボールが下方向に移動中
			if y1 == object_y1:
				# objectの上側と当たった
				is_collideV = True

		if is_collideV and is_collideH:
			# 横方向と縦方向両方から当たった場合
			if x2 - x1 > y2 - y1:
				# 横方向の方が重なりが大きいので横方向に反射
				self.reflectV()
			elif x2 - x1 < y2 - y1:
				# 縦方向の方が重なりが大きいので縦方向に反射
				self.reflectH()
			else:
				# 両方同じなので両方向に反射
				self.reflectH()
				self.reflectV()

		elif is_collideV:
			# 縦方向のみ当たったので縦方向に反射
			self.reflectV()

		elif is_collideH:
			# 横方向のみ当たったので横方向に反射
			self.reflectH()

上記の reflect メソッドは、メソッドを実行したボールを引数 object で指定されるオブジェクトに対して反射させるメソッドになります(ここからはメソッドを実行したボールのオブジェクトを ball と呼ばせていただきます)。

また、上記で実行している reflectHreflectVボールを移動できるようにする で追加したメソッドで、それぞれ ball を横方向に反射する、ball を縦方向に反射するメソッドとなります。

reflect メソッドでは、ballobject のどこと当たったかを確認し、ballobject の左もしくは右と当たった場合は ballreflectH を実行させ、ballobject の上もしくは下と当たった場合は ballreflectV を実行させることでボールの反射を実現しています。

例えば、balldx が正の値の場合、ball は右方向に対して移動していることになります。したがって、ball が当たるとしたら object の左側になります。

実際に当たっているかどうかは、object の左座標である object_x1 と、当たった領域の左座標である x1 が同じであるかどうかで判断することができ、同じである場合、ballobject の左と当たったと判定することができます。

ballがobjectの左側と当たっている時の各座標の関係図

他の例で、例えば balldy が負の値の場合、ball は上方向に対して移動していることになります。したがって、ball がぶつかるとしたら object の下側になります。

実際に当たっているかどうかは、object の下座標である object_y2 と、当たった領域の下座標である y2 が同じであるかどうかで判断することができ、同じである場合、ballobject の下と当たったと判定することができます。

ballがobjectの下側と当たっている時の各座標の関係図

基本はこんな感じで ballobject のどこと当たったかを判定しているのですが、ballobject の2方向に対して当たっている場合があります。

ballがobjectの左側&下側と当たっている時の各座標の関係図

このような場合は、当たった領域の幅と高さを比較し、幅の方が大きい場合は reflectH を実行して横方向に反射するようにしています。それ以外は reflectV を実行して縦方向に反射するようにしています。

当たった領域の幅と高さからどちらの方向にボールを反射させるかを判断する様子

この辺りの判定と反射を is_collideVis_collideH を利用しながら処理しているのが、上記の reflect メソッドとなります。

一点補足しておくと、dxdy が大きい場合、下の図のようにボールがブロックの辺と当たる前にブロックの中に入り込んでしまうことがあります。上記の reflect メソッドは、ブロックの辺とボールが当たった時の反射を行うものであり、ボールがブロックの中に入り込んでしまうと反射がうまくいきません。

objectの辺と当たる前にballがobjectの中に入り込んでしまった様子

dxdy はデータ属性 speed によって大きさが変化しますので、この speed を大きく設定すると上手く動作しない可能性が大きいです(ボールの半径よりも小さくしておくことをおススメします)。そもそも speed が大きいと当たり判定もすり抜けてしまうので、もうちょっといろいろ考えて当たり判定なども作り直す必要があります。

話を戻しまして、上記の reflect メソッドが用意できれば、あとはボールが他のオブジェクトに当たった時に reflect メソッドを実行するようにすれば、他のオブジェクトに当たった際のボールの反射を実現することができます。

このためには、下記のように Breakout クラスの collision メソッドを、当たったブロックを消す前に reflect メソッドを実行するように変更してやれば良いです。

反射の実行
class Breakout:

	def collision(self):
		'''当たり判定と当たった時の処理'''

		for ball in self.balls:

			# 略

			if collided_block is not None:

				# 一番大きく当たったブロックに対してボールを反射
				ball.reflect(collided_block)

				# 一番大きく当たったブロックを削除
				self.delete(collided_block)

ここまでの変更を加えてスクリプトを実行してマウスクリックしてゲームを開始すれば、ボールがブロックに当たった時にブロックが消え、さらにボールが反射していく様子が確認できると思います。

ブロックに当たったボールが反射する様子

他のボールやパドルと当たったボールを反射させる

先程実装したのはブロックと当たった時にボールを反射させる処理になります。なので、現状ではまだ他のボールやパドルと当たった時にはボールがすり抜けてしまいます。

次は他のボールやパドルと当たった時にもボールが反射するようにしていきたいと思います。

ただ、やり方はブロックと当たった時と同じで、getCollisionCoords メソッドにより当たったかどうかを判定し、当たった時に reflect メソッドを実行させてボールを反射させれば良いだけです(ただし、ボールやパドルは当たっても消す必要はありません)。

具体的には、Breakout クラスの collision メソッドを下記のように変更すれば良いです。

他のボールやパドルに対する反射
class Breakout:

	def collision(self):
		'''当たり判定と当たった時の処理'''

		for ball in self.balls:

			#略

			if collided_block is not None:

				# 一番大きく当たったブロックに対してボールを反射
				ball.reflect(collided_block)

				# 一番大きく当たったブロックを削除
				self.delete(collided_block)


			for another_ball in self.balls:
				if another_ball is ball:
					# 同じボールの場合はスキップ
					continue

				# ballとanother_ballとの当たり判定
				if ball.getCollisionCoords(another_ball) is not None:

					# 当たってたらballを反射
					ball.reflect(another_ball)


			# ballとself.paddleとの当たり判定
			if ball.getCollisionCoords(self.paddle) is not None:

				# 当たってたらballを反射
				ball.reflect(self.paddle)

追加した行は全て for ball in self.balls のループの内側の処理であることに注意してください。

ボールが一度に複数のボールと当たる可能性もあるので、ブロックと同様に一番大きく当たったボールに対してのみ反射を行うようにしたほうが良いかも知れませんが、ボール同士が3つ以上一度に当たる可能性も低いので、単純に当たったボール全てに対して反射するようにしています。

上記のように変更を加えてスクリプトを実行してゲームを開始すれば、ボールがパドルや他のボールと当たった時にも反射するようになったことが確認できると思います。ただボールを同じ色にしているのでボール同士が当たった時の反射はちょっと分かりにくいかも…。

ボールがパドルや他のボールと当たった時に反射する様子

これでほぼブロック崩しゲームが出来上がったことになります。あとは、ゲームクリアの表示・ゲームオーバーの表示・ゲームのやり直しを実現していきます。

ゲームクリアを表示する

まずゲームクリア時に "GAME CLEAR" と画面に表示されるようにしていきたいと思います。

まず、ブロック崩しゲームの場合、ゲームクリアは「ブロックが残っているかどうか」で判断することができます。

より具体的には、Breakout クラスのデータ属性 blocks の要素数が 0 になった場合にゲームクリアと判断することができます(ボールと当たったブロックは、blocks からも削除されるようになっている)。

さらに、文字列のキャンバスへの描画はキャンバスの create_text メソッドにより実現できます。

したがって、 Breakout クラスに下記の result メソッドを追加することで、ゲームクリア表示機能を Breakout クラスに持たせることができます。

ゲームクリアの表示
class Breakout:

	def result(self):
		'''ゲームの結果を表示する'''

		if len(self.blocks) == 0:
			self.canvas.create_text(
				self.width // 2, self.height // 2,
				text="GAME CLEAR",
				font=("", 40),
				fill="blue"
			)

			self.is_playing = False

create_text メソッドについても下記ページで解説していますので、詳しく知りたい方はこちらを参照していただければと思います。

tkinterキャンバスに図形を描画する方法解説ページのアイキャッチTkinterの使い方:Canvasクラスで図形を描画する

上記の result メソッドではゲームクリア時に is_playingFalse を設定していますので、"GAME CLEAR" が表示されるとともにゲームも停止することになります。

続いて、下記のように Breakout クラスの loop メソッドで result を実行するように変更します。

resultの実行
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			# ゲーム開始していないなら何もしない
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		# 全ボールを移動する
		for ball in self.balls:
			ball.move()

		self.collision()
		self.updateFigures()
		self.result()

ここまでの変更を加えたスクリプトを実行し、ブロックを全て消せば、画面に "GAME CLEAR" が表示されるようになったことが確認できると思います。

ゲームクリア表示画面

スポンサーリンク

ゲームオーバーを表示する

続いてゲームクリア時同様に、ゲームオーバー時に "GAME OVER" を表示するようにしていきたいと思います。

ボールが画面下に落ちるようにする

ただし、現状作成中のブロック崩しゲームでは絶対にゲームオーバーになりません。なぜなら、ボールが画面下まで行った時も反射するようになっているからです。

なので、ボールが画面下までいったらボールを反射させるのではなく、ボールを消すようにしていきたいと思います。

そのために、まずボールの反射を行なっている Ball クラスの move メソッドを下記のように変更します(差分が分かるようにコメントアウトしていますが、消してしまっても問題ないです)。

下側のボールの反射の停止
class Ball:

	def move(self):
		'''移動'''

		# 移動方向に移動
		self.x += self.dx
		self.y += self.dy

		if self.x < self.x_min:
			# 左の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_min

		elif self.x > self.x_max:
			# 右の壁とぶつかった

			# 横方向に反射
			self.reflectH()
			self.x = self.x_max

		if self.y < self.y_min:
			# 上の壁とぶつかった

			# 縦方向に反射
			self.reflectV()
			self.y = self.y_min

		#elif self.y > self.y_max:
			# 下の壁とぶつかった

			# 縦方向に反射
			#self.reflectV()
			#self.y = self.y_max

画面下に落ちたボールを消す

上記の変更によりボールが画面下から消えるようになりますが、実際にはボールは画面には表示されないもののオブジェクトとしては残っており、描画や当たり判定・移動がまだ行われています。

無駄な処理なので、画面下に落ちてしまったボールはキャンバスや Breakout クラスのデータ属性 balls から消すようにしたいと思います。

これを実現するために、まずはボールが画面下に落ちていないかどうかを判断するための exists メソッドを Ball クラスに追加します。ボールが画面下に落ちていないかどうかは self.y <= self.y_max により判断することができます。

exists
class Ball:

	def exists(self):
		'''画面内に残っているかどうかの確認'''

		return True if self.y <= self.y_max else False

さらに、ボール移動後にボールが画面下に落ちたかどうかを exists メソッドで判断し、画面下に落ちたボールに対しては Breakout クラスの delete メソッドで削除するようにします。

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

ボールの削除
class Breakout:

	def loop(self):
		'''ゲームのメインループ'''

		if not self.is_playing:
			# ゲーム開始していないなら何もしない
			return

		# loopをUPDATE_TIME ms後に再度実行
		self.master.after(UPDATE_TIME, self.loop)

		# 全ボールを移動する
		for ball in self.balls:
			ball.move()


		# ボールが画面外に出たかどうかをチェック
		delete_balls = []
		for ball in self.balls:
			if not ball.exists():
				# 外に出たボールは削除対象リストに入れる
				delete_balls.append(ball)

		for ball in delete_balls:
			# 削除対象リストのボールを削除
			self.delete(ball)

		self.collision()
		self.updateFigures()
		self.result()

ボールがなくなったときにゲームオーバーを表示する

ここまでの変更により、画面下に落ちたボールは Breakout クラスのデータ属性 balls から削除されるようになりました。したがって、全てのボールが画面下に落ちた場合は balls の要素数が 0 になることになり、この要素数からゲームオーバーかどうかを判断できるようになったことになります。

ですので、あとはゲームクリア表示時と同様に、ゲームオーバー時にキャンバスの create_text メソッドにより "GAME OVER" と文字列を描画すれば良いだけです。

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

GAME OVERの描画
class Breakout:

	def result(self):
		'''ゲームの結果を表示する'''

		if len(self.blocks) == 0:
			self.canvas.create_text(
				self.width // 2, self.height // 2,
				text="GAME CLEAR",
				font=("", 40),
				fill="blue"
			)

			self.is_playing = False

		if len(self.balls) == 0:
			self.canvas.create_text(
				self.width // 2, self.height // 2,
				text="GAME OVER",
				font=("", 40),
				fill="red"
			)

			self.is_playing = False

ここまでの変更を加えたスクリプトを実行すれば、ボール全てが画面の下に落ちた際に "GAME OVER" が表示されるようになったことが確認できると思います。

ゲームオーバー表示画面

ゲームのやり直しができるようにする

ほぼここまでの解説でブロック崩しゲームが完成したことになります!

最後に、まぁ正直あってもなくても良い機能ではあるのですが、ゲームクリア or ゲームオーバーになった際にマウスをクリックするだけでゲームを最初からやり直せるようにしたいと思います。

これは、BreakOut クラスの start メソッドを下記のように変更するだけで実現することができます。

ゲームのやり直し
class Breakout:

	def start(self, event):
		'''ゲーム開始'''

		if len(self.blocks) == 0 or len(self.balls) == 0:
			# ゲームクリア or ゲームオーバー時は最初からやり直し

			# キャンバスの図形を全て削除
			self.canvas.delete("all")

			# 全オブジェクトの作り直しと図形描画
			self.createObjects()
			self.drawFigures()

		# ゲーム開始していない場合はゲーム開始
		if not self.is_playing:
			self.is_playing = True
			self.loop()
		else:
			self.is_playing = False

もう忘れているかも知れないので一応言っておくと、start メソッドはマウスクリック時に実行されるメソッドになります。

上記の変更では、ゲームクリア or ゲームオーバー時にキャンバスの図形を全て削除し(canvas.delete("all") により全図形を削除することができる)、さらにオブジェクトを全て作り直してから図形として描画し直すようにしています。

要は、これらの処理を行うことで、ゲーム開始前の状態に戻しています。さらにその後 loop メソッドが実行され、ゲームが開始することになります。

canvas.delete("all") を忘れると、図形がそのままキャンバス上に残ることになるため注意してください。

上記の変更を加えれば、ゲームオーバーやゲームクリアになった後にマウスをクリックすれば、ゲームを最初からやり直せるようになったことが確認できると思います。

ゲームオーバー後にマウスクリックすることでゲームのやり直しができることを示す図

ブロック崩しゲームのカスタマイズ

以上がブロック崩しゲームの作り方の解説になります。

最後に、作成したブロック崩しゲームのカスタマイズについてちょっとだけ解説しておきます。

スポンサーリンク

ブロックの数や空きスペースのサイズ等を変更する

各種パラメータを定義する で解説したように、ブロックの数やサイズ、パドルのサイズ、ボールのサイズや数、空きスペースのサイズ等はスクリプト先頭付近のグローバル変数を変更することで簡単に変化させることができます。

例えば、このグローバル変数を下記のように変更すれば、

パラメータの調整
NUM_H_BLOCK = 15  # ブロッックの数(横方向)
NUM_V_BLOCK = 15  # ブロックの数(縦方向)
WIDTH_BLOCK = 40  # ブロックの幅
HEIGHT_BLOCK = 20  # ブロックの高さ
COLOR_BLOCK = "blue"  # ブロックの色

HEIGHT_SPACE = 0  # 縦方向の空きスペース

WIDTH_PADDLE = 200  # パドルの幅
HEIGHT_PADDLE = 20  # パドルの高さ
Y_PADDLE = 50  # パドルの下方向からの位置
COLOR_PADDLE = "green"  # パドルの色

RADIUS_BALL = 10  # ボールの半径
COLOR_BALL = "red"  # ボールの色
NUM_BALL = 3  # ボールの数

UPDATE_TIME = 20  # 更新間隔(ms)

下のアニメのように内側からブロックを消していくような動作にすることができます。ボールの反射やブロックの削除がどんどん行われていくので、見ていてちょっと気持ちいいかなぁと思います!

内側からブロックが消えていく様子

ただ、ブロックの数やボールの数が増えるとその分当たり判定の処理がたくさん実行されることにな理、処理が重くなるので注意してください(私の環境だとボールが4つあると画面が止まってしまいます…)。

反射角度にランダム性を持たせる

もちろんグローバル変数の変更だけでなく、スクリプトを変更してブロック崩しゲームをカスタマイズしていくこともできます。

例えば Ball クラスの reflectHreflectV を下記のように変更すれば、反射角度にランダム性を持たせることができ、もうちょっと難易度の高いブロック崩しゲームに仕立てることもできます。

反射角度にランダム性を持たせる
class Ball:

	def reflectH(self):
		'''横方向に対して反射'''

		random_angle = math.radians(random.randrange(-30,30))
		self.turn(math.atan2(self.dy, -self.dx) + random_angle)

	def reflectV(self):
		'''縦方向に対して反射'''

		random_angle = math.radians(random.randrange(-30,30))
		self.turn(math.atan2(-self.dy, self.dx) + random_angle)

random モジュールを使用しているため、import random を事前に行っておく必要がありますので、この点に注意してください。

是非カスタマイズに挑戦を!

他にもいろいろカスタマイズできそうなところはあると思います。

私がパッと思いついただけでも下記のようなものがあります。

  • ボールが当たったパドルの位置に応じて反射角度を変える
  • 複数回ボールを当てないと消えないようなブロックを導入する
  • ブロックの配置を変える
  • 処理をもっと軽くする
    • 現状全てのブロックに当たり判定を行なっているけど内側のブロックは当たり判定は不要
  • アイテムを導入する
    • パドルとアイテムが当たったら一定時間パドルを長くする
    • パドルとアイテムが当たったら一定時間ボールがブロックを突き抜けるようにする

ちょっとでもブロック崩しゲームを作るのを楽しいと感じていただけたのであれば、是非こういったカスタマイズに挑戦してみてください!

もちろん解説を読むだけでも知識は付きますが、自身でプログラミングして何か作ってみたり、元々あるアプリをカスタマイズしてみることで、よりプログラミングの力をつけることができると思います!

スポンサーリンク

まとめ

このページでは、Python で tkinter を用いた「ブロック崩しゲームの作り方」を解説しました!

解説は結構長くなってしまいましたが、ブロックが消えたりボールが反射したりするようになったあたりでは「ゲーム作りの楽しさ」を少しは感じていただけたのではないかと思います。

こういった簡単なゲームを作るだけでも考えることはたくさんありますし、実際に作ってみることで多くのことを学べたと思います。

紹介したスクリプトのカスタマイズや他のゲームの開発に挑戦すれば必ずプログラミングの力は付きますので、是非いろんなゲームやアプリ作りに挑戦してみてください!

オススメ参考書(PR)

簡単なアプリやゲームを作りながら Python について学びたいという方には、下記の Pythonでつくる ゲーム開発 入門講座 がオススメです!ちなみに私が Python を始めるときに最初に買った書籍です!

下記ようなゲームを作成しながら Python の基本が楽しく学べます!素材もダウンロードして利用できるため、作成したゲームの見た目にも満足できると思います。

  • すごろく
  • おみくじ
  • 迷路ゲーム
  • 落ち物パズル
  • RPG

また本書籍は下記のような構成になっているため、Python 初心者でも内容を理解しやすいです。

  • プログラミング・Python の基礎から解説
  • 絵を用いた解説が豊富
  • ライブラリの使い方から解説(tkitner と Pygame)
  • ソースコードの1行1行に注釈

ゲーム開発は楽しくプログラミングを学べるだけでなく、ゲームで学んだことは他の分野のプログラミングにも活かせるものが多いですし(キーボードの入力受付のイベントや定期的な処理・画像や座標を扱い方等)、逆に他の分野のプログラミングで学んだ知識を活かしやすいことも特徴だと思います(例えばコンピュータの動作に機械学習を取り入れるなど)。

プログラミングを学ぶのにゲーム開発は相性抜群だと思います。

Python の基礎や tkinter・Pygame の使い方をご存知なのであれば、下記の 実践編 をいきなり読むのもアリです。

実践編 では「シューティングゲーム」や「アクションゲーム」「3D カーレース」等のより難易度の高いゲームを作りながらプログラミングの力をつけていくことができます!

また、単にゲームを作るのではなく、対戦相手となるコンピュータの動作のアルゴリズムにも興味のある方は下記の「Pythonで作って学べるゲームのアルゴリズム入門」がオススメです。

この本はゲームのコンピュータ(AI)の動作アルゴリズム(思考ルーチン)に対する入門解説本になります。例えばオセロゲームにおけるコンピュータが、どのような思考によって石を置く場所を決めているか等の基本的な知識を得ることが出来ます。

プログラミングを挫折せずに続けていくためには楽しさを味わいながら学習することが大事ですので、特にゲームに興味のある方は、この辺りの参考書と一緒に Python を学んでいくのがオススメです!

同じカテゴリのページ一覧を表示