Part 5 の今回は、パドルが壁を通過してしまう問題の修正、衝突するたびにボールのスピードが上がる仕様に変更、プレイヤーの操作でボールが発射される仕様に変更、パドル上のボールが当たった位置によってボールの反射角度が変わる仕様に変更、ついて更新していく。

それでは前回に引き続きブロック崩しを開発していこう。


Memo:
過去のシリーズをまだご覧になっていない方は、そちらを先にご覧いただくことをおすすめします。
Godot で作るブロック崩し


パドルが壁を通過してしまう問題の修正

現状、パドルを画面の右端、左端へ移動させると、そこにあるはずの「Wall」ノードにぶつからずに貫通してしまう。まずはこの問題を修正していく。

「Paddle」ノードには「CollisionShape2D」ノードが、「Wall」ノードには「CollisionPolygon2D」ノードがそれぞれ追加されているため、衝突判定を自動的にしてくれるはずが、なぜか貫通してしまう。

ここで疑うべきはスクリプトだ。では、「Paddle」ノードにアタッチしている「Paddle.gd」スクリプトを見てみよう。

extends KinematicBody2D


export (int) var speed = 150


func _process(delta):
	var direction = Vector2.ZERO
	if Input.is_action_pressed("move_right"):
		direction.x = 1
	if Input.is_action_pressed("move_left"):
		direction.x = -1
	position += speed * direction * delta

_process関数内のposition += speed * direction * deltaの一行が問題だ。これにより、衝突判定を度外視して、「Paddle」ノードの「position」プロパティ(パドルの位置)が壁を超えても更新され続けてしまう。

この問題を修正するために、もともと「KinematicBody2D」クラスに用意されている物理演算でノードを移動してくれるメソッドを使う。それではスクリプト全体を以下の内容に置き換えよう。

extends KinematicBody2D


export (int) var speed = 150

# この1行は削除またはコメントアウト
#func _process(delta):
# この1行を追加
func _physics_process(delta):
	var direction = Vector2.ZERO
	if Input.is_action_pressed("move_right"):
		direction.x = 1
	if Input.is_action_pressed("move_left"):
		direction.x = -1
# この1行を削除またはコメントアウト
#	position += speed * direction * delta
# この1行を追加
	move_and_slide(speed * direction)

変更点は次の2点だ。

  1. _process(delta)_physics_process(delta)に置き換える
  2. position += speed * direction * deltaを関数move_and_slide()に置き換える。

まずは、1つ目の変更点について。

_process関数は、毎フレーム実行される関数で、引数には必ずdeltaをとる。このdeltaというのは、前のフレームにかかった時間を格納している。一方、似たような名前の_physics_process関数も同様にdeltaを引数にとる。この二つの関数の違いが最初はピンと来ないかもしれない。

それぞれの使いどころは公式ドキュメント に以下のように記載されている。

フレームレートに依存するフレーム間のデルタタイムが必要な場合は _process を使用します。オブジェクトデータを更新するコードをできるだけ頻繁に更新する必要がある場合、これが適切な場所です。繰り返しのロジックチェックとデータキャッシングがここで実行されることがよくありますが、更新するために評価が必要になる頻度になります。すべてのフレームを実行する必要がない場合は、Timer-yield-timeoutループを実装することも別のオプションです。

フレームレートに依存しないフレーム間のデルタタイムが必要な場合は _physics_process を使用します。時間の進み具合に関係なく、コードの経時的な一貫した更新が必要な場合は、これが適切な場所です。繰り返しのキネマティックおよびオブジェクトの幾何学変換操作をここで実行する必要があります。

正直、スッと頭に入ってこない説明だ。

要するに、_processは、毎フレーム実行される関数だが、処理が多ければそのフレームにかかる時間が長くなり、当然その長さを表すdeltaの値が大きくなる。さらに、次のフレームで_processが実行されるタイミングが遅くなる。例えば、deltaを使ってあるノードの位置を毎フレーム同じ速度で移動させた場合、極端に処理が多ければ、単位時間あたりのフレーム数が少なくなるので、滑らかな移動ではなくなり、瞬間移動的な動きになってしまうだろう。

一方、_physics_processは、_processとは別の時間軸で動いている。そこフレームの間隔は常に一定(デフォルトでは 60 fps: 1秒間に60フレーム)であるため、deltaの値も毎フレーム同じになり、関数内の処理を実行するタイミングもまた一定間隔になる。だから常に一定間隔で実行したい物理演算処理は_physics_processを使うべき、ということのようだ。

実際には、シーンを実行してみたところで、ブロック崩しくらいの少ないコードであれば_process関数でも同じように動く。しかし、この_physics_process関数内で使用するmove_and_slide関数が、実はdeltaを利用した物理演算を行っているため、厳密には_physics_processの方がより適切だと言える。

次に2つ目の変更点のについて。

この_physics_process関数内で実行されているmove_and_slide()という関数は、「KinematicBody2D」クラスにもともと用意されているメソッドだ。引数をたくさんもつが、第一引数以外はデフォルトの値が決まっているので、基本的には第一引数linear_velocityの値だけ指定してあげれば良い。ちなみに、linear_velocityとは 1 秒あたりのピクセル数で表した速度のことだ。

上のコードでは、この第一引数にspeed * directionを入れている。さらにこの関数はdeltaを自動的にその第一引数に乗じてくれる。結果として 1 フレームあたりの「KinematicBody2D」ノード(ここでは「Paddle」ノード」)の動きを作ってくれるわけだ。

なお、公式ドキュメントmove_and_slideの説明にも、以下のように記載されている。

This method should be used in Node._physics_process (or in a method called by Node._physics_process), as it uses the physics step’s delta value automatically in calculations. Otherwise, the simulation will run at an incorrect speed.

シーンを実行して確認してみよう。これで「Paddle」ノードが壁にぶつかったら止まるようになった。



衝突するたびにボールのスピードが上がるようにする

ずっとボールのスピードが一定だとブロック崩しはとても簡単なゲームのままになってしまう。ボールがどこかに衝突するたびにそのスピードが上がるように変更しよう。必要なスクリプトの更新内容は以下が挙げられる。

  1. スピードの上限を定義する
  2. 一回の衝突あたりに上がるスピードを定義する
  3. 衝突ごとにスピードを上げる

「Ball.gd」スクリプトファイルを以下の内容に更新する。

extends RigidBody2D


const MAX_SPEED = 300.0 # 追加
export (float) var ball_speed = 150.0
export (float) var speed_up = 4.0 # 追加
var direction: Vector2 # 複数の関数で使用する変数のためここで定義
var velocity: Vector2 # 複数の関数で使用する変数のためここで定義


func _ready():
	direction = Vector2(1, -1).normalized() # 上で定義済みなので var を削除
	velocity = direction * ball_speed # 上で定義済みなので var を削除
	apply_impulse(Vector2.ZERO, velocity)


func _on_Ball_body_entered(body):
	ball_speed += speed_up # 追加
	print("ball_speed: "+str(ball_speed)) # 追加
	direction = linear_velocity.normalized() # 追加
	velocity = direction * min(ball_speed, MAX_SPEED) # 追加
	linear_velocity = velocity # 追加
  
	if body.is_in_group("Bricks"):
		body.queue_free()

それでは更新した箇所のうち重要な箇所を説明していこう。

スピードの上限を定義する

const MAX_SPEED = 300.0 # 追加

まず、衝突のたびにボールのスピードを上げるとはいえ、永遠に上がり続けないように上限を設ける。それが上のコードだ。constは定数を定義する。定数とは変数とは違い、一度定義したらその値が変わらない。メモリ管理を最適化する上で、変更の予定がない変数は定数にした方が良い。定数は命名するときに大文字にするのがマナーだ。

衝突ごとに上がるスピードを定義する

export (float) var speed_up = 4.0 # 追加

次に、一回の衝突で上がるスピードをこのコードで定義している。定数でも良いが、こちらは後々調整する可能性が高いので、インスペクタで編集できるようにexportキーワード付きの変数としてspeed_upを定義した。

衝突ごとにスピードを上げる

_on_Ball_body_entered(body)は、「Ball」ノードで以前に「body_entered」シグナルを接続した関数だ。つまり、この関数は「Ball」ノードが何らかのオブジェクトに衝突したら実行される。この関数の中に、コードを追加することによって、衝突ごとにボールの速度を更新してスピードアップを実現している。では、_on_Ball_body_entered(body)関数内に追加したコードを一つずつ見ていこう。

ball_speed += speed_up # 追加
print("ball_speed: "+str(ball_speed)) # 追加

先に定義したspeed_upを現在のスピードであるball_speedに加算している。

その下の行のprint()関数は、デバッグ実行時に出力コンソールに何らかの文字列を表示させたい場合によく利用する。ここでは、現在のスピードを出力コンソールに衝突のたびに出力して、実際にスピードがきちんと上がっているかを確認するために用意した。ゲームプレイには何も影響しないので、開発中に不要になれば、削除かコメントアウトする。

direction = linear_velocity.normalized() # 追加

linear_velocityは、RigidBody2D クラスにもともと用意されているプロパティで、ここでは「Ball」ノードの現在の速度(方向をもった速さ)を示している。最終的にこのプロパティの値を更新することで「Ball」ノードのスピードアップを行うのだが、ここでは一旦、normalized関数によって方向だけの情報を取得してdirection変数にそのVector2型の値を代入している。

velocity = direction * min(ball_speed, MAX_SPEED) # 追加

min関数は第一、第二引数のうち小さい方の値を返す関数だ。つまり_on_Ball_body_entered関数内の計算で、もしball_speedの値がMAX_SPEEDの値を上回ったら、MAX_SPEEDの値が返される。よってこの行では、変数velocityに「Ball」ノードの現在の方向に現在のスピード(または最大スピード)を乗じて得られる速度を代入している。

linear_velocity = velocity # 追加

linear_velocityとは「RigidBody2D」クラスにもともと用意されている、そのノードの速度を表すプロパティだ。「Ball」ノードのlinear_velocityに、先の計算で得られた最新の「Ball」ノードの速度を表すvelocityの値を代入することで、実際の「Ball」ノードの速度を更新している。これで「Ball」ノードのスピードアップ処理が完了する。

実際にプロジェクトを実行してみよう。体感でもスピードが上がる間隔が得られるだろう。うまくプレイして粘るほどパドルのスピードが追いつかなくなるはずだ。実際に出力コンソールにも変数ball_speedの値が出力され、スピードが上がっているのがよくわかる。
出力コンソール

プレイしてみて、もう少しスピードアップのテンポを上げたい(もしくは下げたい)場合は、シーンドックで「Ball」ノードを選択し、インスペクタで「Speed Up」プロパティの値を変更して欲しい。
Ballノードのインスペクタ

同様に、パドルのスピードも遅いと感じたら、「Paddle」ノードの「Speed」プロパティを変更するといいだろう。
Paddleノードのインスペクタ



プレイヤーの操作でボールが発射されるようにする

ゲームが始まるやいなや、すぐにボールが斜め45°の方向へ飛んでいくようにしていたが、やはりゲーム開始時のボールを発射するタイミングはプレイヤーが決めたいものだ。以下の内容で仕様を変更していこう。

  1. 発射用のインプットマップを追加する
  2. プレイヤーがボールを発射するまでボールがパドルから離れないようにする

発射用のインプットマップを追加する

「プロジェクト」メニュー>「プロジェクト設定」>「インプットマップ」タブで、発射用のインプットを追加しよう。「launch_ball」という名前で「スペース」キーを割り当てることにする。
インプットマップの追加

プレイヤーがボールを発射するまでボールがパドルから離れないようにする

ボールの発射をプレイヤーの操作に委ねると、ボールを発射する前にプレイヤーはパドルを左右に動かしたくなるはずだ。その時、ボールがパドルの上にくっついたまま一緒に動く必要がある。

それでは「Ball.gd」ファイルを以下のコードで置き換えよう。

extends RigidBody2D


const MAX_SPEED = 300.0
export (float) var ball_speed = 150.0
export (float) var speed_up = 4.0
var direction: Vector2
var velocity: Vector2
onready var paddle = get_node("../Paddle") # 追加


func _ready():
	direction = Vector2(1, -1).normalized()
	velocity = direction * ball_speed
	# 以下のコードは別の関数内に移動するため削除またはコメントアウト
	#apply_impulse(Vector2.ZERO, velocity)



func _process(delta): # まるごと追加
	if mode == 3:
		position.x = paddle.position.x
		if Input.is_action_just_pressed("launch_ball"):
			mode = 2
			apply_impulse(Vector2.ZERO, velocity) # _ready関数からこちらに移動
			

func _on_Ball_body_entered(body):
	ball_speed += speed_up
	print("ball_speed: "+str(ball_speed))
	direction = linear_velocity.normalized()
	velocity = direction * min(ball_speed, MAX_SPEED)
	linear_velocity = velocity
	
	if body.is_in_group("Bricks"):
		body.queue_free()

それではスクリプトの変更点について解説していく。

onready var paddle = get_node("../Paddle") # 追加

get_node関数の引数に「Paddle」ノードの相対パスを渡すことで、「Paddle」ノードにアクセスできる。つまり、この1行のコードはpaddle変数に「Paddle」ノードが代入された形で定義されている。ちなみに相対パスを表す際の../は、一つ上の階層のノードを指す。つまりここでは「Ball」ノードの親ノードである「Game」ノードを指している。

onreadyキーワードは、ゲームを開始する前の準備段階でその変数を宣言するために使う。この変数paddleをゲーム開始前から定義する必要があるのは、ゲーム開始時からボールが発射されるまでずっと_process関数内で「Paddle」ノードの位置情報が必要になるからだ。

func _process(delta): # まるごと追加
	if mode == 3:
		position.x = paddle.position.x
		if Input.is_action_just_pressed("launch_ball"):
			mode = 2
			apply_impulse(Vector2.ZERO, velocity) # _ready関数からこちらに移動

_process関数を追加した。この関数内のコードは毎フレーム実行される。

modeというプロパティが登場しているが、これは RigidBody2D のプロパティで、オブジェクトをどの種類の物理オブジェクトとして振る舞わせるのかを指定できる。インスペクタにも「Mode」プロパティとして表示されており、以前に「Character」の値を選択していた。しかし、「Character」だと回転はしないがそれ以外は「Rigid」と同じ物理オブジェクトなので、触れている「Paddle」ノードが動くと、その物理的な影響を受けてどこかへ飛んでいってしまう。

ボールがくっついたままパドルを動かせるようにするには、「Mode」プロパティを「Kinematic」にすれば良い。これはインスペクタで設定しよう。なお「Static」にしてしまうと、コードでも「Ball」ノードの位置を変更できず、パドルだけ動いて、ボールは中に浮いたままの状態になってしまう。
インスペクタでModeをKinematicにする

コードの話に戻ろう。modeenum として定義されており、 0 ~ 3 の値をとる。それぞれの値は以下のとおり、物理オブジェクトの種類を意味している。

  • 0: Rigid
  • 1: Static
  • 2: Character
  • 3: Kinematic

つまり、if mode == 3:は、「Ball」ノードの「Mode」プロパティが「Kinematic」だったら、という意味になる。さっきインスペクタで「Kinematic」を選択したので、初期値は必ず3である。もし3の場合は、まだボールを発射していないので、「Ball」ノードの x 座標を「Paddle」ノードの x 座標と常に同じにする、というのがposition.x = paddle.position.xだ。positionプロパティにそのノードの現在の位置が Vector2 型のデータとして格納されている。.xでそのうちの x 座標の値だけを取得しているというわけだ。

さらに続けて、もし3の場合は、プレイヤーがスペースキーを押した場合にボール発射の処理を実行する。『スペースキーを押した場合は…』というのがif Input.is_action_just_pressed("launch_ball"):のコードだ。そして、この2つ目の if 構文内ですぐにmode = 2として、「Mode」プロパティをもともと設定していた「Character」に変更している。「Character」になったあと、もとは_ready関数内で実行していたapply_impulse(Vector2.ZERO, velocity)を実行して、ボールを発射している。そして「Mode」プロパティが「Kinematic」では無くなったので、if mode == 3:の判定が false になる。つまり、ボールが発射された後は_process関数内の処理は実行されなくなり、ボールはパドルの x 座標と同じになる縛りから解放される。


では、プロジェクトを実行して、実際にスペースキーを押して発射されるまでボールがパドルに乗ったまま移動するのかを確認してみよう。
デバッグパネルで発射までボールがパドルから離れないことを確認


パドル上のボールが当たった位置によりボールの反射角度を変える

今の時点では、ゲーム開始からボールが何かに当たると45°の角度でしか跳ね返らない。このままでは誰がいつプレイしても毎回同じボールの動きにしかならず、おもしろくない。そこで、パドルにボールが当たった位置によって、跳ね返る角度が変化するように変更していく。

どのように角度を決定するかというと、パドルに当たった時のボールの位置とパドルの位置の差分を Vector2 型の値として取得し、その値をそのまま跳ね返る角度に利用する、というロジックだ。

それでは、改めて「Ball.gd」ファイルを以下のコードで置き換えよう。

extends RigidBody2D


const MAX_SPEED = 300.0
export (float) var ball_speed = 150.0
export (float) var speed_up = 4.0
var direction: Vector2
var velocity: Vector2
onready var paddle = get_node("../Paddle")


func _ready():
	direction = Vector2(1, -1).normalized()
	velocity = direction * ball_speed


func _process(delta):
	if mode == 3:
		position.x = paddle.position.x
		if Input.is_action_just_pressed("launch_ball"):
			mode = 2
			apply_impulse(Vector2.ZERO, velocity)
			

func _on_Ball_body_entered(body):
	ball_speed += speed_up
	print("ball_speed: "+str(ball_speed))
	direction = linear_velocity.normalized()
	velocity = direction * min(ball_speed, MAX_SPEED)
	
	if body.is_in_group("Bricks"):
		body.queue_free()
	
	if body.get_name() == "Paddle": # まるごと追加
		direction = (position - body.position).normalized()
		velocity = direction * min(ball_speed, MAX_SPEED)
	
	linear_velocity = velocity # 一番下に移動

変更したのは、_on_Ball_body_entered関数の中のみだ。

if body.get_name() == "Paddle": # まるごと追加
		direction = (position - body.position).normalized()
		velocity = direction * min(ball_speed, MAX_SPEED)

この if 構文をまるまる一つ追加した。

if body.get_name() == "Paddle":で、ボールが衝突した対象がパドルだったら、という意味合いだ。もしパドルだった場合に実行する処理が if 構文の中のコードだ。

シンプルにボールの位置とパドルの位置の差分を Vector2 型のデータとして取得して、それをnormalized関数に通せば、方向の情報だけを持った Vector2 型の値が取り出せる。その値をdirection変数に代入したのがdirection = (position - body.position).normalized()だ。

現在のボールのスピードは変数ball_speedに格納されているので、「速度 = 方向 x 速さ」に当てはめる形で、速度の変数velocityに最終的な速度を代入しているのがvelocity = direction * min(ball_speed, MAX_SPEED)だ。

そして、linear_velocity = velocityの一行をこの関数ブロックの最後に持ってきて、実際のボールの速度にパドルに跳ね返る時の角度を反映させた格好だ。

それではプロジェクトを実行して、実際にパドルに当たる位置でボールの角度が変わるか見てみよう。
デバッグパネルでボールがパドルに跳ね返る時の角度を確認

上のGIF画像で、最後にあっけなくボールを落としてしまっているが、パドルの当たる位置によってボールの跳ね返る角度が変わっているのがわかるだろう。



おわりに

以上で Part 5 は完了だ。やや細かいが以下の4つの更新を行った。

  • パドルが壁を通過してしまう問題の修正
  • 衝突するたびにボールのスピードが上がるようにする
  • プレイヤーの操作でボールが発射されるようにする
  • パドル上のボールが当たった位置によりボールの反射角度を変える

少し細かい内容だったかもしれないが、確実にブロック崩しのゲーム性が向上し、またあなた自身の Godot やゲーム開発全般のスキルが上がったのではないだろうか。

次回 Part 6 ではスタート画面、ゲームオーバー画面を追加してゲームらしさにさらに磨きをかけていく。