Part 14 の今回は、ブロック崩しのブロックの種類を増やして、複数のレベル(ステージ)をデザインしていく。併せてゲームクリア画面も作成する。
Memo:
過去のシリーズをまだご覧になっていない方は、そちらを先にご覧いただくことをおすすめします。
Godot で作るブロック崩し
ブロックの種類を増やす
今回用意するブロックの種類は、オーソドックスに以下の3種類とする。
- NORMAL: 1回ボールが当たったら消えるブロック(これまでと同じ仕様)
- HARD: 2回ボールが当たったら消えるブロック
- METAL: 何度当てても消えないブロック
Brick.gd スクリプトを更新する
ブロック用のシーンはこれまで同様「Brick.tscn」のみとし、「Brick.gd」スクリプトにプロパティとメソッドを追加することでブロックの種類を設定できるようにしていく。
それでは「Brick.gd」スクリプトの更新内容を見ていこう。
tool
extends StaticBody2D
enum Hardness{
NORMAL,
HARD,
METAL,
} # 追加
export (Hardness) var brick_hardness = Hardness.NORMAL setget set_color # 追加
#export (Color) var brick_color = Color(1, 1, 1, 1) setget set_color # 削除
新たに enum Hardness
を定義した。要素はブロックの種類に対応したNORMAL
、HARD
、METAL
の3つだ。
変数brick_color
を削除し、代わりに変数brick_hardness
を定義した。この変数に先ほど定義した enumHardness
の要素を割り当てる。一旦、初期値はNORMAL
としたが、export
キーワード付きで定義しているので、インスペクタドックから適宜、変更可能だ。これにより、今後いくつかのレベルシーンをデザインする際に、2D ワークスペースでブロックを配置しつつ、手軽にブロックの種類も変更できるので効率的だ。
次にset_color
メソッドを更新した。
func set_color(hardness): # 更新
brick_hardness = hardness
var brick_color: Color
match hardness:
Hardness.METAL:
brick_color = Color.darkgray
Hardness.HARD:
brick_color = Color.firebrick
Hardness.NORMAL:
brick_color = Color.white
if is_inside_tree():
sprite.set_modulate(brick_color)
これまでは引数はcolor
だったがhardness
に変更した。元々この引数に渡していた変数brick_color
は今回削除した。代わりに、引数には変数brick_hardness
を渡すことになったので、わかりやすい引数名に変更したというわけだ。
このset_color
メソッドは、変数brick_hardness
のセッター関数にもなっているので、brick_hardness = hardness
を最初に記述している。
メソッドの中で変数brick_color
を定義し、データ型をColor
とした。この変数の値はこの後のmatch
構文で決定される。
match
構文では、引数hardness
の値を判定する形にした。hardness
の値が enumNORMAL
、HARD
、またはMETAL
の場合で分岐させている。ではmatch
構文で分岐した後のそれぞれのコード内容を見ていこう。
hardness
が enumMETAL
だった場合:変数brick_color
にdarkgray
(ダークグレー)に代入する。hardness
が enumHARD
だった場合:変数brick_color
にColor.firebrick
(レンガ色)を代入する。hardness
が enumNORMAL
だった場合:変数brick_color
にColor.white
(白)を代入する。
ちなみにGodot 公式ドキュメント > Color をご覧いただき、あなたのお好みの色を設定していただいてもOKだ。
set_color
メソッドの最後のif
構文は変更なしで、is_inside_tree
メソッドによりシーンツリーにこのノードが存在するかどうかを判定し、存在すれば、子ノード「Sprite」の色に変数brick_color
の色を適用する。
最後に_ready
メソッドの内容を確認しよう。
func _ready():
set_color(brick_hardness) # 引数を変更
modulate = Color(1, 1, 1, 1)
sprite.scale = Vector2(1, 1)
ほとんど変更はないが、set_color
メソッドの引数には元々、変数brick_color
を渡していた。しかし、それは今回削除し、メソッドの内容も先ほど変更したところだ。よって、引数には今回新しく定義した変数brick_hardness
を渡すのが正解だ。これにより、変数brick_hardness
の値がどのブロックの種類かによって、ゲーム開始前にブロックの色も自動的に設定される。
複数のレベルをデザインする
ここからは、先ほど作成した3種類のブロックを使って、複数のレベルをデザインしていく。
レベルの雛形シーンを作る
「Level1.tscn」シーンの名前を「Level_base.tscn」に変更し、それをそのまま雛形として使用していく。ルートノードの名前も「Level1」から「Level」(1だけ削除)に変更しておこう。
雛形シーンを複製してレベルのシーンを量産する
レベルシーンをデザインする(ブロックの種類を選択したり、配置したりする)際、雛形を「継承」したシーンではノードの削除ができない。つまり、デザイン上、不要なブロックがあっても削除できない。継承なしでレベルシーンを量産する方法は以下の2通りある。
- ファイルシステムドックで「Level_base.tscn」を右クリック>「複製」を選択する
- シーンドックで「Level_base.tscn」を開き、「シーン」メニュー>「名前を付けてシーンを保存」を選択する
おそらく前者の方が手順が効率的なので、このチュートリアルではそちらで作業を進めていく。では、以下の手順を繰り返し、レベル 10 くらいまで作成していこう。
- ファイルシステムドックで「Level_base.tscn」を右クリック>「複製」を選択する
- シーンの名前を「Level_.tscn」(例:Level3.tscn)にして「複製」をクリックする
- ルートノードの名前の末尾にレベルの数字を追加する(例:Level3)
もちろん、この後のレベルシーンのデザインと並行して、都度シーンを作成しても良い。その場合は、用意した雛形シーン「Level_base.tscn」から複製するのではなく、デザイン済みのレベルシーンを複製しても構わない。お好みの手順で進めてほしい。
それぞれのレベルをデザインする
作成したシーンを 2D ワークスペースでデザインしていこう。基本的にここはあなたの思うようにやっていただいて問題ない。効率的にデザインするための Tips だけ説明しておく。
- 2D ワークスペース上でブロック(ノード)を複数選択する場合は、shift キーを押しながらノードをクリックする。
- ブロックの種類を変更する場合は、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、インスペクタドックから「Brick Hardness」プロパティの値を変更する。複数ノードを選択していれば同時に変更可能。
- ブロックを複製するときは、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、ショートカットキー操作(ノードの削除 Windows: Ctrl + D / macOS: Cmd + D)が便利。ちなみに、複製直後は複製元のノードに完全に重なっている(positionプロパティの値が同じ)ため、複製したら移動先が決まっていなくても一旦位置を少しズラすことをお勧めする。
- ブロックを削除するときは、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、ショートカットキー操作(ノードの削除 Windows: Del / macOS: Cmd + BkSp)が便利。複数ノードを選択していれば同時に削除可能。
- 実際のゲーム画面でのブロックの位置を確認したい時は、プロジェクトではなくシーンを実行する。
レベルシーンのサンプル
レベル 1 から レベル 10 までのシーンのデザインサンプルを紹介しておく。是非、あなたがレベルシーンをデザインする時の参考にしてほしい。
レベル 1 ~ 10 のシーンのデザインを見る
- レベル 1:
- レベル 2:
- レベル 3:
- レベル 4:
- レベル 5:
- レベル 6:
- レベル 7:
- レベル 8:
- レベル 9:
- レベル 10:
ブロックにアニメーションとサウンドエフェクトを追加する
ブロックの種類が増えたので、HARD ブロック、および METAL ブロックにボールが衝突した時のアニメーションとサウンドエフェクトを追加していこう。なお、NORMAL ブロックには今まで使用してきたアニメーションとサウンドエフェクトを使用する。
ブロックのアニメーションを追加する
HARD ブロック、 METAL ブロックには NORMAL ブロック用の既存のアニメーション「collided」を複製、編集したものを割り当てていく。
まずは「Brick.tscn」シーンを開いたら、下準備として「collided」を複製して2つの新しいアニメーションを用意しよう。以下の手順で複製できる。
- アニメーションパネルでアニメーション「collided」を選択した状態から上部の「アニメーション」をクリックする。
- 表示されたメニューから「複製」を選択する。
- 同じメニューから「名前の変更」を選択して、複製されたアニメーションの名前を変更する。アニメーションの名前は「hard_collided」および「metal_collided」としておこう
ここまでできたら、次は新しく用意したアニメーションをそれぞれ編集していく。
アニメーション hard_collided の編集
まずは「hard_collided」から編集作業を進めよう。
アニメーション「hard_collided」は、HARD ブロックにボールが当たった時に再生したい。「modulate」プロパティのトラックは、ブロックの透明度を上げていって最後に消えるアニメーションだ。これは不要なので削除しておこう。トラックの右端にあるゴミ箱アイコンをクリックすれば削除できる。
なお、HARD ブロックにボールが当たると NORMAL ブロックに変わる仕組みをのちほど実装していく。
「position」プロパティのトラックはそのまま残しておこう。
「scale」プロパティのトラックは少し編集する。まず、0.2 秒のところにキーを挿入し、Value を (0.8, 0.8) にする。
同様にして、0.4 秒のところのキーの Value は (1, 1)にする。
再生してみると以下の GIF 画像のようになる。
「hard_collided」の編集はこれで完了だ。
アニメーション metal_collided の編集
次は「metal_collided」の方を編集していこう。こちらは METAL ブロックにボールが当たった時に再生するアニメーションだ。METL ブロックにボールが当たってもびくともしない様を演出したい。
まず「position」プロパティのトラックだ。キーの挿入位置はそのままにしておこう。以下のように、それぞれのキーの Value (x, y)の絶対値を0.5
を0.2
に変更しよう。
- 0 秒: (0.2, 0.2)
- 0.05秒: (-0.2, -0.2)
- 0.1秒: (0.2, -0.2)
- 0.15秒: (-0.2, 0.2)
- 0.2秒: (0.2, 0.2)
- 0.25秒: (-0.2, -0.2)
- 0.3秒: (0.2, -0.2)
- 0.35秒: (-0.2, 0.2)
- 0.4秒: (0, 0)
「scale」プロパティのトラックは、ブロックのサイズが変化するアニメーションだ。これはイメージに合わないので削除しておこう。
最後に「modulate」プロパティのトラックを編集する。METAL ブロックの「無敵感」を演出するため、ブロックの色を立て続けに一瞬間ごとに切り替えるアニメーションを作成する。これには「modulate」プロパティのトラックに短い間隔で複数キーを挿入し、それぞれのキーの Value に虹色の構成色を順番に割り当てるようにして編集していく。具体的な手順は以下の通りだ。
- 0 秒: #dfff0000
- 0.05秒: #dfff9b00
- 0.1秒: #dffdff00
- 0.15秒: #df00ff10
- 0.2秒: #df00fff9
- 0.25秒: #df007cff
- 0.3秒: #df4400ff
- 0.35秒: #dfcd00ff
- 0.4秒: #a8a8a8(METAL ブロックのデフォルトの色 darkgray と同じ)
これでアニメーションの作成は完了だ。
サウンドエフェクトを追加する
次は HARD ブロック、METAL ブロックにボールが衝突した時のサウンドエフェクトを追加していく。
サウンドエフェクトファイルをファイルシステムに追加する
今回も「Bfxr」アプリケーションを使用して、サウンドエフェクトのファイルを用意した。Dropbox に追加しているので必要に応じてダウンロードしてほしい。
Memo:
以下のリンク先のフォルダから「brick_collided_hard.wav」および「brick_collided_metal.wav」ファイルをダウンロードいただけます。
Dropbox の sounds フォルダ
サウンドエフェクトのファイルが用意できたら、Godot のファイルシステムドックへドラッグ&ドロップして追加しよう。
AudioStreamPlayer ノードを追加・編集する
「Brick.tscn」シーンを引き続き編集していく。
ルートノード「Brick」に「AudioStreamPlayer」クラスのノードを2つ追加し、それぞれの名前を「HardCollideSound」「MetalCollideSound」とする。
「HardCollideSound」ノードの「Stream」プロパティに、先ほどファイルシステムに追加した「brick_collided_hard.wav」を適用する。
同様に「MetalCollideSound」ノードの「Stream」プロパティには、「brick_collided_metal.wav」を適用する。
これでサウンドエフェクトに必要なノードの追加と編集は完了だ。
Ball.gd スクリプトを更新する
「Ball.gd」スクリプトを編集していこう。追加する内容は、主にボールがブロックに衝突した時のブロックの種類による条件分岐と、分岐後のアニメーション、サウンドエフェクトの再生だ。
今回の編集の対象となるのは、スクリプト内の_on_Ball_body_entered
メソッドのみだ。このメソッドは、ボールがいずれかの物理オブジェクトと衝突した時に発信されるシグナルbody_entered
によって呼ばれる。よって、基本的には衝突したオブジェクトがブロックだった場合のコード、つまりif body.is_in_group("Bricks"):
のブロック内のコードを更新していけば良い。
では具体的に更新した_on_Ball_body_entered
メソッドを見てみよう。
func _on_Ball_body_entered(body):
ball_speed += speed_up
direction = linear_velocity.normalized()
velocity = direction * min(ball_speed, MAX_SPEED)
if body.is_in_group("Bricks"): # 大幅に更新
var collide_sound # 追加
var animation = body.get_node("AnimationPlayer")
match body.brick_hardness: # 追加
body.Hardness.METAL:
collide_sound = body.get_node("MetalCollideSound")
collide_sound.play()
animation.play("metal_collided")
body.Hardness.HARD:
collide_sound = body.get_node("HardCollideSound")
collide_sound.play()
animation.play("hard_collided")
body.brick_hardness = body.Hardness.NORMAL
body.Hardness.NORMAL:
collide_sound = body.get_node("CollideSound")
collide_sound.play()
animation.play("collided")
yield(animation, "animation_finished")
body.queue_free()
#(後略)
if body.is_in_group("Bricks"):
のブロック内で二つの変数を定義している。
まず一つ目の変数collide_sound
には「AudioStreamPlayer」クラスのノードを値として入れる予定だが、定義した時点ではまだ何も値を入れていない。このクラスのノードがブロックの種類に合わせて3つ存在するからだ。のちほどmatch
構文でブロックの種類によって分岐させ、それぞれの条件下で適切なノードを参照させる。
変数animation
はこれまで同様Brick
ノードの子ノードAnimationPlayer
を参照している。サウンドエフェクトと異なり、一つのAnimationPlayer
ノードで複数のアニメーションを実行できる。
match
構文だが、Brick
シーンの変数brick_hardness
の値によって分岐させている。
まずbrick_hardness
がHardness.METAL
だった場合(つまり、ブロックの種類が METAL だった場合)、変数collide_sound
には「MetalCollideSound」ノードを参照させ、play
メソッドにより METAL ブロック用のサウンドエフェクトを再生する。続けて、アニメーションmetal_collided
を再生する。
次にbrick_hardness
がHardness.HARD
だった場合、変数collide_sound
には「HardCollideSound」ノードを参照させ、HARD ブロック用のサウンドエフェクトを再生する。続けて、アニメーションhard_collided
を再生する。その後body.brick_hardness = body.Hardness.NORMAL
のコードにより、ブロックの種類を HARD から NORMAL に変更する。これによって、セッターのset_color
メソッドが呼ばれ、スプライトの色も NORMAL ブロックの白に切り替わる。これで HARD ブロックは、ボールが 2 回衝突すると消える仕組みができた。
最後にbrick_hardness
がHardness.NORMAL
だった場合だが、この分岐後のコードはこれまでボールがブロックに衝突した時に実行されていたものと全く同じである。
以上で「Ball.gd」スクリプトの更新は完了だ。
Game.gd スクリプトを更新する
次にゲーム全体に関わる制御を実装していく。
変更を加えるのは、「Game.gd」スクリプト内の_on_Brick_tree_exited
メソッドだ。このメソッドはブロックが消えた時に毎回発信されるシグナルtree_exited
によって呼ばれる。
func _on_Brick_tree_exited(brick_position):
# Update Score
score += POINT * bonus_rate
bonus_rate += 0.1
hud_score.text = "Score: " + str(score)
# Exit current Level node
var no_brick = true # 追加
for child in level.get_children(): # 追加
if child.brick_hardness != child.Hardness.METAL: # 追加
no_brick = false
break
#if level.get_child_count() <= 0: # 削除
if no_brick: # 追加
set_next_level()
else:
# Drop powerup item
drop_powerup(brick_position)
#(後略)
まず、no_brick
という変数を bool 値true
で定義した。NORMAL ブロックまたは HARD ブロックのオブジェクトが一つも無い状態がtrue
、一つでもあればfalse
となる。現在のレベルの「Level」ノードの子ノード(「Brick.tscn」シーンのインスタンス)全てに対してfor
ループを回していく。ループ内のif
構文の条件は「もしそのブロックが METAL ではなかったら」と定義している。これに当てはまる場合、NORAML または HARD のブロックが存在することになるので、変数no_brick
の値をfalse
として、break
でループを抜ける。一方、NORMAL も HARD も存在しない場合は、変数no_brick
は初期値のtrue
のままでfor
ループを終える。
次に、これまでは「Level」ノードに子ノード(つまり「Brick.tscn」のインスタンスノード)がなくなったら、という条件で次のレベルに切り替えていたが、それを意味するif level.get_child_count() <= 0:
というコードは今回削除した。その代わりに METAL を除くブロックが全てなくなっていることを示すno_brick
がtrue
の場合に次のレベルに切り替えるように記述した。
これで「Game.gd」スクリプトの編集は完了だ。
ゲームクリア画面を作る
ゲームクリアシーンを作る
すべてのレベルをクリアした時に表示されるゲームクリア画面を作成しておこう。表示する内容はゲームオーバー画面とあまり変わらないため、「GameOverView.tscn」を複製する形で用意しよう。
シーンができたら、いくつかのノードの名前に「GameOver」の文言が含まれるので、それを「GameClear」に変更していく。具体的には以下のノードだ。
- 「GameOverView」>「GameClearView」
- 「GameOverLabel」>「GameClearLabel」
- 「GameOverBGM」>「GameClearBGM」
名前編集後、シーンツリーは以下のようになっているはずだ。
ゲームオーバー画面のラベルがそのまま残っているので、修正する。まず、シーンドックで「GameClearLabel」ノードを選択したら、インスペクタドックで「Text」プロパティを「game clear」に書き換える。
そして、「Custom Color」>「Font Color」プロパティの値(色)を「00ff88」(緑色)に変更する。ゲームオーバーの時の色が赤だったので、補色の関係にした。
ゲームクリア画面で流れるBGMを追加する
ゲームクリア画面での BGM も追加しておこう。
今回も魔王魂さん にお世話になった。使用させて頂いたのは「オーケストラ02 」という BGM だ。明るい雰囲気の BGM なのでゲームクリアのタイミングで流れると雰囲気としてはマッチしそうだ。ゲームに使用している BGM に一貫性がないが大目に見て欲しい。もちろん、別の BGM を選んでも良いし、ご自身で作成していただいても構わない。特にこだわりがなければ、リンクからこのチュートリアルと同じ BGM をダウンロードしてしまおう。こちらのファイルは取り扱いのルール上 Dropbox にはアップロードしていないのでご了承いただきたい。
ゲームクリア画面用の BGM のファイルが用意できたら、名前を「bgm_game_clear.ogg」などとして、Godot のファイルシステムドックへドラッグ&ドロップして追加しよう。
ファイルシステムに BGM ファイルを追加できたら、シーンドックで「GameClearBGM」ノード(「AudioStreamPlayer」クラス)を選択し、そのファイルをインスペクタドックの「Stream」プロパティめがけてドラッグ&ドロップする。これで既存の「GameOverView.tscn」で使用していた BGM ファイルが置き換えられる。
「GameOverView.tscn」を複製したので、「Pitch Scale」プロパティが0.8
になったままのはずだ。これをデフォルトの1
に戻しておこう。
すべてのレベルをクリアしたらゲームクリア画面に遷移させる
用意しているすべてのレベルをクリアしたらゲームクリア画面に切り替わるように、スクリプトを編集する。編集する対象は「Game.gd」スクリプトだ。なお「GameClearView」ノードにアタッチされているスクリプト「GameOverView.gd」はそのままで問題ない。
このスクリプトのset_next_level
メソッドを以下のように編集する。
func set_next_level():
print("set_next_level() called")
# Change status
is_playing = false
is_multiple_on = false
is_laser_on = false
# Clear left objects
level.queue_free()
for child in get_children():
if child.is_in_group("Balls") or child.is_in_group("Lasers"):
child.queue_free()
# Save data
save_data() # 追加
# Increment level number
level_num += 1
# If no more level, game clear
if ResourceLoader.exists("res://scene/Level" + str(level_num) + ".tscn"): # 追加
# Stop PauseScreen node
pause_screen.pause_mode = 1
# Show NextScreen node
next_screen.pause_mode = 2
next_screen_level.text = "Level: " + str(level_num)
next_screen_score.text = "Score: " + str(score)
next_screen_life.text = "x " + str(life)
next_screen.show()
# Set Level of HUD the next level
hud_level.text = "Level: " + str(level_num)
# Set next Level node
add_new_level()
# Pause game until NextScreen is hidden
get_tree().paused = true
else:
get_tree().change_scene("res://scene/GameClearView.tscn")
print("no more level!")
編集したのは「# 追加」とコメントを記載している箇所だ。
まずsave_data
メソッドをlevel_num += 1
のコードの前に追加した。レベルクリア時点で毎回データを保存するようにした形だ。変数level_num
に1
を加算する前(レベルの数字が1上がる前)に、last_level
とhigh_level
のデータを保存する必要があるため、この位置に挿入した。
もう一つ追加したのがlevel_num += 1
のコードのすぐあとのif / else
構文だ。GDScript にはResourceLoader
というクラスがあり、そのexists
メソッドを利用して、引数で渡したファイルパスに該当するリソースが存在するかどうかを確認できる。戻り値がtrue
であれば存在し、false
であれば存在しない、ということになる。したがって、上記のスクリプトでは、次のレベルの .tscn ファイルが存在すれば、今まで通り次のレベルへの切り替え処理を行い、存在しなければ、最後のレベルをクリアしたとして、change_scene
メソッドでゲームクリア画面へ遷移するようになっている。
ところで上述の通り、set_next_level
メソッド内にsave_data
メソッドを追加したが、データを保存する前にハイスコア、ハイレベルを更新する必要がある。そこで_on_Ball_tree_exited
メソッドから、ハイスコア、ハイレベルを更新する 4 行のコードをsave_data
メソッド内へ移動させることにする。
func _on_Ball_tree_exited():
#(中略)
if no_ball:
if is_playing:
life -= 1
if life <= 0:
# if high_score < score: # 削除
# high_score = score
# if high_level_num < level_num: # 削除
# high_level_num = level_num
save_data()
get_tree().change_scene("res://scene/GameOverView.tscn")
#(後略)
func save_data():
if high_score < score: # 追加
high_score = score
if high_level_num < level_num: # 追加
high_level_num = level_num
var data = {
"last_level": level_num,
"high_level": high_level_num,
"last_score": score,
"high_score": high_score,
}
var file = File.new()
file.open(SCORE_FILE_PATH, File.WRITE)
file.store_line(to_json(data))
file.close()
これでsave_data
メソッドを実行するだけで、必ずハイスコア、ハイレベルのデータが最新の状態で保存されるようになった。
以上でゲームクリア画面の作成は完了だ。
それでは、このあとデバッグしやすくなるように、変数level_num
にexport
キーワードを付けて、インスペクタで値を変更可能にしておこう。
export var level_num: int = 1 # 変更
さっそく「Game」ノードを選択して、インスペクタから「Level Num」プロパティの値をあなたが作った最後のレベルの数字に変更しよう。このチュートリアルのサンプルはレベル 10 までなので、プロパティの値は10
に変更した。
さらに「Game.gd」スクリプトの_ready
メソッド内も、デバッグ用に更新する。
func _ready():
randomize()
add_new_level()
add_new_ball()
update_hud_life()
load_data()
# For debug
leave_one_brick(73) # 変更
leave_one_brick
メソッドのコメントアウトを解除し、引数には、すぐにボール当てられるブロックのノード名の末尾の数字を渡そう。例として、このチュートリアルでサンプルとして作成したレベル 10 のシーンでは73
を指定して、「Brick73」ノードのみを残すようにした。あなたの作った最後のレベルシーンのノードを確認して適切な値を入れて欲しい。
では、プロジェクトを実行して挙動を確認しよう。チェックする内容は以下の通りだ。
- 最後のレベルをクリアしたらゲームクリア画面に遷移するか
- 正しいスコアが表示されるか
- ゲームクリア画面でキーを押下してスタート画面に戻るか
エラーの対応、バグの修正
先ほどプロジェクトを実行した時、ゲームクリア画面に遷移するタイミングで以下のようなエラーが出力された。
Resumed function ’enable_multiple_balls()’ after yield, but script is gone. At script: res://scripts/Game.gd:211
上記エラーはパワーアップ「Multiple」を発動するメソッドenable_multiple_balls
に関する内容だが、他に「Expand」や「Laser」でも同様のエラーが出てしまう状況だ。
これはパワーアップアイテムの有効時間を表すタイマーが切れる前に、シーンを「GameClearView.tscn」に切り替えたことが原因だ。「Game.gd」スクリプトでは、yield
関数によって各パワーアップのタイマーがtimeout
シグナルを発信するまで待機しており、timeout
してメソッドが Resume(再開)される時にはすでにシーンが切り替わって「Game.gd」スクリプトがないため、次のコードを読み込めずエラーを吐いている。
これを修正するにはどうやらyield
の使用を止める必要がありそうだ。代わりにTimer
クラスのインスタンスを用意して、同様の処理を行っていく。
では「Game.gd」スクリプトを修正していこう。
まずは変数を3つ追加する。
var expand_timer = Timer.new() # 追加
var multiple_timer = Timer.new() # 追加
var laser_timer = Timer.new() # 追加
「Expand」「Multiple」「Laser」それぞれのパワーアップが有効になった時にカウント開始する3つのタイマーだ。Timer
クラスのノード生成して変数に代入している。これらのタイマーは繰り返し使用することになる。
次に_ready
メソッドだ。
func _ready():
randomize()
add_new_level()
add_new_ball()
update_hud_life()
load_data()
set_timer(expand_timer, "stop_expand") # 追加
set_timer(multiple_timer, "stop_multiple") # 追加
set_timer(laser_timer, "stop_laser") # 追加
leave_one_brick(73)
「# 追加」とコメントしている3行を追加した。その3行で実行されているset_timer
メソッドはこのあと定義しているので見ていこう。
func set_timer(timer_var, stop_func):
add_child(timer_var)
timer_var.connect("timeout", self, stop_func)
必要な引数は2つ。1つ目がタイマーの変数、2つ目がタイマーを止めるメソッド名。なお、その2つ目の引数に入れるメソッドはのちほど定義する。
メソッドの内容は、まずadd_child
メソッドにて、タイマーをこのスクリプトがアタッチされている「Game」ノードの子ノードに追加する。続いて、connect
メソッドにより、タイマーのtimeout
シグナルをstop_func
に接続する。
続いて、それぞれのパワーアップを発動するメソッドの修正と、タイマーを停止するメソッドの定義を行っていく。
まずはパワーアップ「Expand」に関わるメソッドを見てみよう。
func expand_paddle():
expand_collide_sound.play()
if paddle.scale <= paddle_scale:
paddle.scale.x *= 2
#yield(get_tree().create_timer(3), "timeout") # 削除
#paddle.scale = paddle_scale # 削除
expand_timer.wait_time = 10 # 追加
expand_timer.start() # 追加
func stop_expand(): # 追加
expand_timer.stop()
paddle.scale = paddle_scale
expand_paddle
メソッドから、yield
関数とpaddle.scale = paddle_scale
のコードを削除した。一方、タイマーの時間をセットするためにwait_time
プロパティに10
秒を指定している。続けてタイマーをスタートさせるために変数start
メソッドを実行させている。
さらにタイマーを止めるためのメソッドとしてstop_expand
を新たに定義した。このstop_expand
メソッドには_ready
メソッド内でtimeout
シグナルが接続されているので、タイマーの待機時間が0
になったら呼ばれる。呼ばれたらexpand_timer.stop()
が実行されて、タイマーが止まる。併せてexpand_paddle
メソッドから移動したpaddle.scale = paddle_scale
のコードによりパドルの長さを初期値に戻している。
「Multiple」および「Laser」に関しても同様の更新を行った。コードは以下の通りだ。
func enable_multiple_balls():
multiple_collide_sound.play()
if not is_multiple_on:
is_multiple_on = true
#yield(get_tree().create_timer(3), "timeout") # 削除
#is_multiple_on = false # 削除
multiple_timer.wait_time = 3 # 追加
multiple_timer.start() # 追加
func stop_multiple(): # 追加
multiple_timer.stop()
is_multiple_on = false
func enable_laser():
laser_collide_sound.play()
if not is_laser_on:
is_laser_on = true
#yield(get_tree().create_timer(3), "timeout") # 削除
#is_laser_on = false # 削除
laser_timer.wait_time = 3 # 追加
laser_timer.start() # 追加
func stop_laser(): # 追加
laser_timer.stop()
is_laser_on = false
最後にset_next_level
メソッドも更新する。
func set_next_level():
print("set_next_level() called")
# Change status
is_playing = false
#is_multiple_on = false # 削除
#is_laser_on = false # 削除
stop_expand() # 追加
stop_multiple() # 追加
stop_laser() # 追加
#(後略)
このset_next_level
メソッドが呼ばれるのは、各レベルをクリアしたタイミングだ。その時、もしまだパワーアップ「Expand」「Multiple」「Laser」が有効だったら、タイマーもカウントダウン中なので、それをストップさせるのにstop_expand
、stop_multiple
、stop_laser
メソッドを追加した。
is_multiple_on = false
とis_laser_on = false
のコードは、それぞれstop_multiple
メソッドとstop_laser
メソッドの中に記述されていて不要になったので削除した。
少々コード量が増えてしまったが、これで仕組みはほぼそのままで、yield
に関わるエラーが解消されたはずだ。
あとは、デバッガパネルに黄色丸 🟡 で表示されるエラー(アラート)のうち、引数があるのに一度も使ってなくて怒られているものは、引数名の前にアンダースコアを付けて対処しよう。デバッガパネルのエラーをクリックすると、スクリプトパネルでスクリプトの該当箇所を表示してくれる。
The argument ’event’ is never used in the function ‘_input’. If this is intended, prefix it with an underscore: ‘_event’
例えば「NextScreen.gd」スクリプトの_input
メソッドの引数を(event)
から(_event)
に変更する。
func _input(_event):
#(後略)
一方、デバッガパネルに黄色丸 🟡 で表示されるエラー(アラート)のうち、戻り値があるのに一度も使ってなくて怒られているものは、変数に代入することでも対処可能だが、このブロック崩しのチュートリアルではそのままにしておく。
The function ‘change_scene()’ returns a value, but this value is never used.
現時点で確認できているバグも修正しておこう。
あまり気にならないので見逃していたが、「Ball.gd」スクリプトの変数ball_speed
に、first_speed
のデフォルトの値でしか代入されない問題があった。これはインスペクタドックで「First Speed」プロパティの値を、例えば、50
にしてみても、実際にゲームのプレイ開始時はデフォルトの150
になってしまうというものだ。
onready var ball_speed = first_speed # 変更
このようにonready
キーワードを付けてあげると、first_speed
がインスペクタで指定した値に更新されてからball_speed
に代入されるので、問題が解消する。
もう一つバグも修正する。
ハイレベルは10が最大のはずが、18 や 20、31 などはるかに大きい数値が記録されるバグが見つかった。実は先のGIF画像のゲームクリア画面でもそのような結果になっている。
確認すると、最後のブロックを消したあと、「Game.gd」スクリプト内のset_next_level
メソッドが複数回呼ばれ、そのメソッド内で変数level_num
に1
が加算されて、値が大きくなることがわかった。これは、最後のブロックを消したあと、残っている METAL ブロックが自動的に消される時に、その残っている数だけlevel_num
の数値が大きくなっていのが原因だった。
そこで「Game.gd」スクリプト内のadd_new_level
メソッドで、METALブロックは_on_Brick_tree_exited
メソッドにシグナルを接続しないようにすることで解決できる。シグナルを接続しなければ、METAL ブロックが最後にいくつ残っていても_on_Brick_tree_exited
メソッドが呼ばれることはなく、その結果set_next_level
メソッドが複数回呼ばれることもなくなる、というわけだ。
メソッドは以下のように編集した。
func add_new_level():
level = load("res://scene/Level" + str(level_num) + ".tscn").instance()
add_child(level)
move_child(level, 3)
for child in level.get_children():
if child.brick_hardness != child.Hardness.METAL: # 追加
child.connect("tree_exited", self, "_on_Brick_tree_exited", [child.global_position])
以上で、現状見つかっているバグの修正は完了だ。
ゲームバランスに関わる定数、変数の値の見直し
最後にゲームバランスを整えるために、いくつかの定数、変数の値を更新していく。
Game.gd スクリプト
「Game.gd」スクリプトでは以下のように変更した。
const POINT = 10
一つのブロックを消して100
ポイントも得られると、ボーナスも合わさってすぐに桁数が大きくなってしまうので一桁減らした。
Ball.gd スクリプト
「Ball.gd」スクリプトでは以下のように変更した。
export (float) var first_speed = 120.0
export (float) var speed_up = 1.0
難易度が高すぎたので、最初のスピードをもっと落として、スピードアップのテンポも落とした。
Paddle.gd スクリプト
「Paddle.gd」スクリプトでは以下のように変更した。
export (int) var speed = 300
ボールが最高速度300
になってもプレイヤーのテクニックさえあればついていけるようにパドルの速度を上げた。
自分で作ったレベルをプレイしてみる
せっかく色々なレベルをデザインしたので自分でも遊んでみよう。
このチュートリアルでサンプルとして作ったレベルのうち、レベル9はパワーアップアイテムの内容によってはなかなか厳しいステージだった。レベル10と順番が逆ではないかと思うほどだ。
実際にプレイしてみて、難易度が想像と違った場合は、レベルのデザイン自体を変更しても良いし、レベルの順番を変えても良い。一プレイヤー目線で、どこが良くてどこがダメなのか感じながらプレイし、その体感を自分へのフィードバックとして一つずつ改善に役立てるようにすると、ゲームがより良くなっていくだろう。
おわりに
以上で Part 14 は完了だ。今回はブロックの種類を増やし、それを利用してレベルを複数デザインした。ついでにゲームクリア画面も用意し、ブロック崩しゲームとしてひとまず形にすることができた。
次回 Part 15 は、ゲームの書き出し作業に関するチュートリアルだ。そして、次回がこのブロック崩しのチュートリアルの最終回となる。