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を定義した。要素はブロックの種類に対応したNORMALHARDMETALの3つだ。

変数brick_colorを削除し、代わりに変数brick_hardnessを定義した。この変数に先ほど定義した enumHardnessの要素を割り当てる。一旦、初期値はNORMALとしたが、exportキーワード付きで定義しているので、インスペクタドックから適宜、変更可能だ。これにより、今後いくつかのレベルシーンをデザインする際に、2D ワークスペースでブロックを配置しつつ、手軽にブロックの種類も変更できるので効率的だ。

インスペクタドックでBrick Hardnessを表示


次に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の値が enumNORMALHARD、またはMETALの場合で分岐させている。ではmatch構文で分岐した後のそれぞれのコード内容を見ていこう。

  • hardnessが enumMETALだった場合:変数brick_colordarkgray(ダークグレー)に代入する。
  • hardnessが enumHARDだった場合:変数brick_colorColor.firebrick(レンガ色)を代入する。
  • hardnessが enumNORMALだった場合:変数brick_colorColor.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 くらいまで作成していこう。

  1. ファイルシステムドックで「Level_base.tscn」を右クリック>「複製」を選択する
    ファイルシステムドックからレベルシーンを複製
  2. シーンの名前を「Level_.tscn」(例:Level3.tscn)にして「複製」をクリックする
    名前を変更して複製を確定
  3. ルートノードの名前の末尾にレベルの数字を追加する(例:Level3)
    ルートノードの名前の末尾にレベルの数字を追加する

もちろん、この後のレベルシーンのデザインと並行して、都度シーンを作成しても良い。その場合は、用意した雛形シーン「Level_base.tscn」から複製するのではなく、デザイン済みのレベルシーンを複製しても構わない。お好みの手順で進めてほしい。


それぞれのレベルをデザインする

作成したシーンを 2D ワークスペースでデザインしていこう。基本的にここはあなたの思うようにやっていただいて問題ない。効率的にデザインするための Tips だけ説明しておく。

  • 2D ワークスペース上でブロック(ノード)を複数選択する場合は、shift キーを押しながらノードをクリックする。
    2D ワークスペース上でノードを複数選択する
  • ブロックの種類を変更する場合は、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、インスペクタドックから「Brick Hardness」プロパティの値を変更する。複数ノードを選択していれば同時に変更可能。
    Brick Hardnessの値を選択
  • ブロックを複製するときは、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、ショートカットキー操作(ノードの削除 Windows: Ctrl + D / macOS: Cmd + D)が便利。ちなみに、複製直後は複製元のノードに完全に重なっている(positionプロパティの値が同じ)ため、複製したら移動先が決まっていなくても一旦位置を少しズラすことをお勧めする。
  • ブロックを削除するときは、シーンドック、または 2D ワークスペース上で対象のブロックノードを選択し、ショートカットキー操作(ノードの削除 Windows: Del / macOS: Cmd + BkSp)が便利。複数ノードを選択していれば同時に削除可能。
    ノードを削除する
  • 実際のゲーム画面でのブロックの位置を確認したい時は、プロジェクトではなくシーンを実行する。
    シーンを実行する

レベルシーンのサンプル

レベル 1 から レベル 10 までのシーンのデザインサンプルを紹介しておく。是非、あなたがレベルシーンをデザインする時の参考にしてほしい。

レベル 1 ~ 10 のシーンのデザインを見る
  • レベル 1:
    レベル1のデザイン
  • レベル 2:
    レベル2のデザイン
  • レベル 3:
    レベル3のデザイン
  • レベル 4:
    レベル4のデザイン
  • レベル 5:
    レベル5のデザイン
  • レベル 6:
    レベル6のデザイン
  • レベル 7:
    レベル7のデザイン
  • レベル 8:
    レベル8のデザイン
  • レベル 9:
    レベル9のデザイン
  • レベル 10:
    レベル10のデザイン


ブロックにアニメーションとサウンドエフェクトを追加する

ブロックの種類が増えたので、HARD ブロック、および METAL ブロックにボールが衝突した時のアニメーションとサウンドエフェクトを追加していこう。なお、NORMAL ブロックには今まで使用してきたアニメーションとサウンドエフェクトを使用する。


ブロックのアニメーションを追加する

HARD ブロック、 METAL ブロックには NORMAL ブロック用の既存のアニメーション「collided」を複製、編集したものを割り当てていく。

まずは「Brick.tscn」シーンを開いたら、下準備として「collided」を複製して2つの新しいアニメーションを用意しよう。以下の手順で複製できる。

  1. アニメーションパネルでアニメーション「collided」を選択した状態から上部の「アニメーション」をクリックする。
    アニメーションをクリック
  2. 表示されたメニューから「複製」を選択する。
    メニューから複製を選択
  3. 同じメニューから「名前の変更」を選択して、複製されたアニメーションの名前を変更する。アニメーションの名前は「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を再生
「hard_collided」の編集はこれで完了だ。


アニメーション metal_collided の編集

次は「metal_collided」の方を編集していこう。こちらは METAL ブロックにボールが当たった時に再生するアニメーションだ。METL ブロックにボールが当たってもびくともしない様を演出したい。

まず「position」プロパティのトラックだ。キーの挿入位置はそのままにしておこう。以下のように、それぞれのキーの Value (x, y)の絶対値を0.50.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_collidedを再生

これでアニメーションの作成は完了だ。


サウンドエフェクトを追加する

次は HARD ブロック、METAL ブロックにボールが衝突した時のサウンドエフェクトを追加していく。


サウンドエフェクトファイルをファイルシステムに追加する

今回も「Bfxr」アプリケーションを使用して、サウンドエフェクトのファイルを用意した。Dropbox に追加しているので必要に応じてダウンロードしてほしい。

Memo:
以下のリンク先のフォルダから「brick_collided_hard.wav」および「brick_collided_metal.wav」ファイルをダウンロードいただけます。
Dropbox の sounds フォルダ

サウンドエフェクトのファイルが用意できたら、Godot のファイルシステムドックへドラッグ&ドロップして追加しよう。
hard_collidedを再生


AudioStreamPlayer ノードを追加・編集する

「Brick.tscn」シーンを引き続き編集していく。

ルートノード「Brick」に「AudioStreamPlayer」クラスのノードを2つ追加し、それぞれの名前を「HardCollideSound」「MetalCollideSound」とする。
hard_collidedを再生

「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_hardnessHardness.METALだった場合(つまり、ブロックの種類が METAL だった場合)、変数collide_soundには「MetalCollideSound」ノードを参照させ、playメソッドにより METAL ブロック用のサウンドエフェクトを再生する。続けて、アニメーションmetal_collidedを再生する。

次にbrick_hardnessHardness.HARDだった場合、変数collide_soundには「HardCollideSound」ノードを参照させ、HARD ブロック用のサウンドエフェクトを再生する。続けて、アニメーションhard_collidedを再生する。その後body.brick_hardness = body.Hardness.NORMALのコードにより、ブロックの種類を HARD から NORMAL に変更する。これによって、セッターのset_colorメソッドが呼ばれ、スプライトの色も NORMAL ブロックの白に切り替わる。これで HARD ブロックは、ボールが 2 回衝突すると消える仕組みができた。

最後にbrick_hardnessHardness.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_bricktrueの場合に次のレベルに切り替えるように記述した。

これで「Game.gd」スクリプトの編集は完了だ。



ゲームクリア画面を作る

ゲームクリアシーンを作る

すべてのレベルをクリアした時に表示されるゲームクリア画面を作成しておこう。表示する内容はゲームオーバー画面とあまり変わらないため、「GameOverView.tscn」を複製する形で用意しよう。
複製してGameClearViewを作成

シーンができたら、いくつかのノードの名前に「GameOver」の文言が含まれるので、それを「GameClear」に変更していく。具体的には以下のノードだ。

  • 「GameOverView」>「GameClearView」
  • 「GameOverLabel」>「GameClearLabel」
  • 「GameOverBGM」>「GameClearBGM」

名前編集後、シーンツリーは以下のようになっているはずだ。
GameClearView.tscn のシーンツリー

ゲームオーバー画面のラベルがそのまま残っているので、修正する。まず、シーンドックで「GameClearLabel」ノードを選択したら、インスペクタドックで「Text」プロパティを「game clear」に書き換える。
GameClearLavelのTextプロパティ

そして、「Custom Color」>「Font Color」プロパティの値(色)を「00ff88」(緑色)に変更する。ゲームオーバーの時の色が赤だったので、補色の関係にした。
GameClearLavelのFont Colorプロパティ


ゲームクリア画面で流れるBGMを追加する

ゲームクリア画面での BGM も追加しておこう。

今回も魔王魂さん にお世話になった。使用させて頂いたのは「オーケストラ02 」という BGM だ。明るい雰囲気の BGM なのでゲームクリアのタイミングで流れると雰囲気としてはマッチしそうだ。ゲームに使用している BGM に一貫性がないが大目に見て欲しい。もちろん、別の BGM を選んでも良いし、ご自身で作成していただいても構わない。特にこだわりがなければ、リンクからこのチュートリアルと同じ BGM をダウンロードしてしまおう。こちらのファイルは取り扱いのルール上 Dropbox にはアップロードしていないのでご了承いただきたい。

ゲームクリア画面用の BGM のファイルが用意できたら、名前を「bgm_game_clear.ogg」などとして、Godot のファイルシステムドックへドラッグ&ドロップして追加しよう。
bgm_game_clear.oggをファイルシステムへ追加

ファイルシステムに BGM ファイルを追加できたら、シーンドックで「GameClearBGM」ノード(「AudioStreamPlayer」クラス)を選択し、そのファイルをインスペクタドックの「Stream」プロパティめがけてドラッグ&ドロップする。これで既存の「GameOverView.tscn」で使用していた BGM ファイルが置き換えられる。
Streamプロパティにbgm_game_clear.oggを追加

「GameOverView.tscn」を複製したので、「Pitch Scale」プロパティが0.8になったままのはずだ。これをデフォルトの1に戻しておこう。
Pitch Scaleプロパティをデフォルトの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_num1を加算する前(レベルの数字が1上がる前)に、last_levelhigh_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_numexportキーワードを付けて、インスペクタで値を変更可能にしておこう。

export var level_num: int = 1 # 変更

さっそく「Game」ノードを選択して、インスペクタから「Level Num」プロパティの値をあなたが作った最後のレベルの数字に変更しよう。このチュートリアルのサンプルはレベル 10 までなので、プロパティの値は10に変更した。
Level Num の値を変更

さらに「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_expandstop_multiplestop_laserメソッドを追加した。

is_multiple_on = falseis_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_num1が加算されて、値が大きくなることがわかった。これは、最後のブロックを消したあと、残っている 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 は、ゲームの書き出し作業に関するチュートリアルだ。そして、次回がこのブロック崩しのチュートリアルの最終回となる。