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点だ。
_process(delta)
を_physics_process(delta)
に置き換える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」ノードが壁にぶつかったら止まるようになった。
衝突するたびにボールのスピードが上がるようにする
ずっとボールのスピードが一定だとブロック崩しはとても簡単なゲームのままになってしまう。ボールがどこかに衝突するたびにそのスピードが上がるように変更しよう。必要なスクリプトの更新内容は以下が挙げられる。
- スピードの上限を定義する
- 一回の衝突あたりに上がるスピードを定義する
- 衝突ごとにスピードを上げる
「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」プロパティの値を変更して欲しい。
同様に、パドルのスピードも遅いと感じたら、「Paddle」ノードの「Speed」プロパティを変更するといいだろう。
プレイヤーの操作でボールが発射されるようにする
ゲームが始まるやいなや、すぐにボールが斜め45°の方向へ飛んでいくようにしていたが、やはりゲーム開始時のボールを発射するタイミングはプレイヤーが決めたいものだ。以下の内容で仕様を変更していこう。
- 発射用のインプットマップを追加する
- プレイヤーがボールを発射するまでボールがパドルから離れないようにする
発射用のインプットマップを追加する
「プロジェクト」メニュー>「プロジェクト設定」>「インプットマップ」タブで、発射用のインプットを追加しよう。「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
は enum として定義されており、 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 ではスタート画面、ゲームオーバー画面を追加してゲームらしさにさらに磨きをかけていく。