Part 7 の今回は、HUD(ヘッドアップディスプレイ)を作っていく。HUD というのは、例えば、プレイヤーのライフゲージやスコア、残り時間、レベル(ステージ)の番号などのように、ゲームプレイ画面に常に表示されているもののことだ。

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


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


HUDの大まかなレイアウトをデザインする

ゲーム画面のデザインを作るとき、いきなり Godot でシーンを追加していくのは得策ではない。なぜなら、大まかなレイアウトが決まっていない状態では、「ああでもない、こうでもない」とノードの追加と削除を繰り返し、なかなかゴールに辿り着けなくなる恐れがあるからだ。

デザインの作業をする時は、まずをラフスケッチ作るところから始めよう。使う道具は紙と鉛筆でもいいし、PowerPoint や Keynote などスライド作成アプリでもいい。気軽に始められるツールで作業しよう。

今回は、このラフスケッチを基に「Game」シーンにノードを追加していく。ちなみにこのスケッチは iPad のメモアプリに ApplePencil で書いた。
HUDの下書き



HUD を作る

さきほどのラフスケッチに、さらにノードのクラスを加筆してみたのがこれだ。
HUDの下書きにノード追記

それでは「Game」シーンに HUD を追加していこう。


ノードを追加する

まずはHUDに必要なノードを一気に追加していこう。

  1. 「Game」ノードに「Control」ノードを追加し、名前を「HUD」に変更
  2. 「HUD」ノードに「ColorRect」ノードを追加し、名前を「Backgournd」に変更
  3. 「HUD」ノードに「VBoxContainer」ノードを追加し、名前を「LeftBox」に変更
  4. 「HUD」ノードに「HBoxContainer」ノードを追加し、名前を「RightBox」に変更
  5. 「LeftBox」ノードに「Label」ノードを2つ追加し、それぞれの名前を「Level」、「Score」に変更
  6. 「RightBox」ノードに「TextureRect」ノードを追加し、名前を「Life1」に変更

ここまでできたら、次はノードの並び順を更新しよう。

ノードの並び順を変更する

一般的に HUD はゲーム画面の最前面に表示されることが多いが、ブロック崩しでは、プレイ中に HUD の背後にボールが隠れてしまわないように、HUD を最背面に配置する。

原則として、シーンドック内で下にあるノードほどゲーム画面では前面に表示される。ノード追加時の仕様上、「HUD」ノードを追加した直後は最前面(シーンドックでは一番下)に配置されているだろう。「HUD」ノードを「Game」ノードの最背面に配置するには、単純にシーンドック内で「Game」ノードのすぐ下に「HUD」ノードをドラッグ&ドロップすれば良い。

ここまでできたら、シーンドック上の「HUD」関連ノードの並びは以下のようになったはずだ。
シーンドックでHUDの配置確認


シーンの背景を黒にする

HUDの「Level」や「Score」の文字は白で表示したい。白を強調するために、先に「Game」シーンの背景を黒にしてしまおう。

  1. 親の「HUD」ノードを画面いっぱいに広げるため、シーンドックで「HUD」ノードを選択して、ツールバーから「レイアウト」>「Rect全面」を選択する。
  2. シーンドックで「HUD」ノードの子ノード「Backgournd」を選択して、同様にツールバーから「レイアウト」>「Rect全面」を選択する。
  3. インスペクタで「Backgournd」ノードの「Color」プロパティを黒(000000)に設定する。
    BackgourndのColorプロパティを黒に

シーンを実行して確認

以下のように表示されたら想定通りだ。
デバッグパネルでBackgourndの表示確認


HUD のレベルとスコアを作る

「LeftBox」ノードの編集

次に HUD の左側にレベルとスコアの表示を作っていく。「LeftBox」ノードを編集していこう。以下の手順で「LeftBox」のレイアウトを調整する。

  1. シーンドックで「LeftBox」ノードを選択する
  2. ツールバーで「レイアウト」>「左上」を選択する
  3. ツールバーでグリッドスナップを有効にする
  4. 移動モードを有効にし、2D ワークスペースで右と下に 1 グリッド(8 px)分移動する
  5. 選択モードを有効にして、2D ワークスペースで枠の右側を x 座標 320 まで広げる

2D ワークスペースでLeftBox移動

インスペクタで「Margin」プロパティを見るとこのようになっているはずだ。もちろん上記のような 2D ワークスペースでの直感的操作ではなく、直接インスペクタ上でプロパティの数値を入力しても問題ない。
LeftBoxのMarginプロパティ


「Level」ノードと「Score」ノードの「Text」プロパティの編集

続いて「LeftBox」ノードに追加した2つの「Label」ノードを編集していく。

  1. 「Level」ノードの「Text」プロパティに「Level: 1」と入力する
  2. 「Score」ノードの「Text」プロパティに「Score: 0」と入力する

これで HUD の左側に「Level」と「Score」が表示された。実際の数字はスクリプトでプレイ中に随時更新されるようにしていくが、一旦初期値としてこの状態にしておく。
2D ワークスペースでLevelとScoreの確認

フォントはデフォルトのままでも違和感ないが、せっかくなので、チュートリアル Part 6 でダウンロードした「PressStart2P-Regular.ttf」を適用しよう。軽く手順をおさらいしておく。

Memo:
「PressStart2P-Regular.ttf」のフォントがファイルシステムドックに見当たらない場合は、Google Fonts のサイト から「Press Start 2P」というフォントをダウンロードして、ファイルシステムに改めて追加してください。


「Level」ノードの編集

  1. シーンドックで「Level」ノードを選択し、インスペクタで「Custom Fonts」セクションを開く
  2. 「Font」プロパティ(一番上の方)で「新規 DynamicFont」を選択する。
  3. 「Font」プロパティ(四番目の方)の[空]に、ファイルシステムから「PressStart2P-Regular.ttf」をドラッグ&ドロップする。
  4. 「Size」プロパティの値を12にする。
    2D ワークスペースでLevelとScoreの確認

「Score」ノードの編集

「Score」ノードも同様に設定するが、フォントファイルはインスペクタ上でコピー&ペーストができるのでその方法で設定しよう。

  1. 「Level」ノードのインスペクタで「Custom Fonts」を開き、「Font」プロパティ(四番目の方)の「Font Data」右側のプルダウンをクリックして「コピー」を選択する。
    2D ワークスペースでLevelとScoreの確認
  2. 「Score」ノードのインスペクタに切り替えて、「Custom Fonts」を開き、「Font」プロパティ(一番上の方)で「新規 DynamicFont」を選択する。
  3. 続けて「Font」プロパティ(四番目の方)の「Font Data」右側のプルダウンをクリックして「貼り付け」を選択する。
    2D ワークスペースでLevelとScoreの確認

これでカスタムフォントの設定ができた。

シーンを実行して確認

シーンを実行して、レベルとスコアの表示を確認しておこう。
2D ワークスペースでLevelとScoreの確認


HUD のライフを作る

次は HUD の右側にライフを表示させる。

「RightBox」ノードの編集

以下の手順で「RightBox」ノードのレイアウトを調整する。

  1. シーンドックで「RightBox」ノードを選択する
  2. ツールバーで「レイアウト」>「右上」を選択する
  3. 移動モードを有効にして、左と下に 1 グリッド(8 px)分移動する
  4. 選択モードを有効にして、枠の左側を x 座標 320 まで広げる
    2D ワークスペースでRightBoxの編集
  5. インスペクタで「Alignment」プロパティの値を「End」に変更しておく。これで子ノードが右寄せで配置される。
    RightBoxのAlignmentプロパティの確認

インスペクタでプロパティを見るとこのようになっているはずだ。さきほどの「LeftBox」の時と同様に、「Margin」プロパティに関しては、直接インスペクタ上で数値を入力してもOKだ。
RightBoxのMarginプロパティの確認


「Life1」ノードの編集

次に「RightBox」の子ノード「Life1」ノードを編集していこう。「TextureRect」クラスは「Sprite」クラス同様、画像ファイルを割り当ててそれを表示することができる。

  1. シーンドックで「Life1」ノードを選択する。
  2. ファイルシステムの「res://sprites/Heart3.png」(一番小さいハート)をインスペクタの「Texture」プロパティの[空]めがけてドラッグ&ドロップする。
    ファイルシステムドックからTextureプロパティへドラッグ&ドロップ

これで画像がセットされた。
インスペクタでTextureプロパティ確認

2D ワークスペースにはこのように表示されたはずだ。ただ、残念ながら、画像が元のサイズのままだと少し大きすぎる。もう少し小さく控えめにしていく。
2DワークスペースでLife1を確認

ではインスペクタで「Life1」ノードの必要なプロパティを以下の手順で変更していこう。

  1. まずは「Expand」プロパティを「オン」にする。「Strech Mode」プロパティはそのまま。
    Expandをオン、Strech ModeをScale On Expandにする
  2. 「Rect」>「Min Size」プロパティの x, y をそれぞれ 16 にする。これはノードのサイズの最小値だ。ここを(16, 16)としておくことで、親ノードの「RightBox」のサイズは子ノードの最初値(16, 16)より小さくすることはできない。
    Min Sizeを(16, 16)にする
  3. 「RightBox」を選択モードにして枠の下側を 2 グリッド分(16 px)上げて、ちょうど「Life1」ノードにフィットするように上下幅を小さくする。一見回りくどい設定の仕方であり、「Life1」ノードだけを単に 16 px 上に移動させれば良いのでは、と思った方もいるかもしれない。しかし、「Containter」系のクラスの子ノードはそれ自体の位置を変更することができない。位置は完全に親の「Container」クラスに支配されているのだ。
    Min Sizeを(16, 16)にする

改めて「RightBox」のインスペクタでプロパティを見るとこのようになっているはずだ。
RightBoxのMaginプロパティ

「Life1」のプロパティ設定が終わったところで、シーンドックで「Life1」ノードを4つ複製して「Life5」まで量産しよう。このような作業はショートカットキーが便利だ(複製 > Windows: Ctrl + D / macOS: Cmd + D)。

シーンドックでこのようになっていればOKだ。
シーンドックでLife1からLife5まで確認

2D ワークスペースの方で見ると、このようにHBoxContainer内で横並びに配置される。
2D ワークスペースのLife1からLife5

ここまでの作業で、シーンドックはこのようになっている。
シーンドックのHUD


シーンを実行して確認

実際にシーンを実行して、HUD の表示が最初のラフスケッチとだいたい同じになったか確認しておこう。
デバッグパネルでHUDを確認



HUD をプレイ状況と連動させる

ここからはゲームプレイの状況に応じて HUD の表示が変化するように、スクリプトも使って連動させていく。連動させたい内容は以下の3つだ。

  • 現在のレベルを表示させる
  • 現在のスコアを表示させる
  • 現在のライフを表示させる

では順番に作業を進めていこう。


現在のレベルを表示させる

以下の作業が必要になるが、一つずつやっていこう。

  • 次のレベルのシーンを追加する
  • 次のレベルに行く前の準備画面を作る
  • スクリプトで HUD のレベルを変化させる

次のレベルのシーンを追加する

ブロックの配置などこだわり出すと時間がかかるので、ここではひとまず仮で次のレベルのシーンである「Level2」というシーンを作成する。一番簡単な方法は、すでに作成済みの「Level1」シーンを継承して作成する方法だ。

Memo:
継承とは、オブジェクト指向のプログラミングでは大事な概念の一つです。他のクラスのプロパティやメソッドをそのまま引き継いで新たなクラスを作成することを指します。さらに内容を上書きして更新することもでき、また親のクラスを変更すると、自動的に継承した子クラスにも変更が反映されます。

ではまず「シーン」メニュー>「新しい継承シーン」を選択する。
「シーン」メニュー>「新しい継承シーン」

次に 「Level1.tscn」ファイルを選択すれば、「Level1」シーンをまるまま継承したシーンができる。すぐにルートノードの名前を「Level2」に変更しておこう。
「Level2」に名前変更

そのまま新しく作ったシーン「Level2」を「Level2.tscn」という名前で保存すればひとまず2つ目のシーンの出来上がりだ。
「Level2.tscn」でシーンを保存


次のレベルに行く前の準備画面を作る

現状、レベルごとの準備画面がない。例えば「Level1」のブロックを全て消した後「Level2」に移行する時に、このままだとすぐに次のLevel2のブロックが画面に配置され、落ち着く暇もない。一呼吸置く意味でも、準備画面を作成していこう。準備画面には HUD と同じく以下の項目を表示させるよう構成する。

  • 次のレベル
  • 現在のスコア
  • ライフ

準備画面として「NextScreen」という名前のノードを「Game」ノードに追加し、レベルの切り替え時のみ画面に表示されるようにしていく。以下の手順で作業を進めていこう。

  1. では「Game」ノードに「Control」ノードを追加し、名前を「NextScreen」に変更する。
  2. 2D ワークスペースで「NextScreen」を選択モードにして、手動でサイズをプレイ画面全体に広げる。
    「NextScreen」ノードを画面いっぱいに広げる
  3. 「NextScreen」ノードに「ColorRect」ノードを追加し、名前を「Background」に変更する。
  4. 2D ワークスペースで「Background」を「レイアウト」>「Rect全面」で親ノードと同じだけ広げる。
  5. インスペクタで「Background」の「Color」プロパティをお好みの色に変更する(例えば #106ed1)
    「Background」ノードの状態
  6. 「NextScreen」ノードに「VBoxContainer」ノードを追加し、名前を「VBox」に変更する。インスペクタで「Alignment」プロパティの値を「Center」にする。さらに「Custom Constants」セクションの「Separation」プロパティの値を24にする。
  7. 「VBox」ノードに「Label」ノードを2つ追加し、名前をそれぞれ「Level」、「Score」とする。
  8. インスペクタで「Level」ノードの「Text」プロパティの値を「Level: 1」とし、「Align」プロパティを「Center」にする。
  9. 同様に、「Score」ノードの「Text」プロパティの値を「Score: 0」とし、「Align」プロパティを「Center」にする。
  10. 「VBox」ノードに「HBoxContainer」ノードを追加し、名前を「HBox」に変更する。インスペクタで「Alignment」プロパティの値を「Center」にする。さらに「Custom Constants」セクションの「Separation」プロパティの値を12にする。
  11. 「HBox」ノードに「TextureRect」ノードを追加し、名前を「HeartImage」に変更する。インスペクタで「Texture」プロパティに、ファイルシステムから「res://sprites/Heart3.png」を割り当てる。続けて「Expand」プロパティをオンにし、「Rect」>「Min Size」プロパティを (24, 24) にする。
  12. 「HBox」ノードに「Label」ノードを追加し、名前を「Life」に変更する。インスペクタで「Text」プロパティに「x 3」と入力する。
  13. 「Level」、「Score」、「Life」の3つの Label クラスのノードにカスタムフォントを設定する。「新規 DynamicFont」を設定し、「Font」プロパティにファイルシステムから「PressStart2P-Regular.ttf」を適用する。「Font」>「Size」プロパティは16のままで良い。
  14. シーンドックで「VBox」ノードを選択し、ツールバーの「レイアウト」>「中央」を選択する。
    2Dワークスペースで「NextScreen」ノードの確認

さて、「NextScreen」ノードを追加したあと、シーンドックはこのようになっているだろうか。
シーンドックで「NextScreen」の位置確認

大事なポイントとして、「Game」ノードの子ノードのうち、「NextScreen」ノードが一番下に位置している必要がある。理由はゲームプレイ中は「NextScreen」ノードを非表示にしておき、一つのレベルをクリアして次のレベルをスタートする前に準備画面として表示し、その際、他のノードを覆い隠すためだ。


スクリプトで HUD のレベルを変化させる

では、ここからスクリプトを作成していく。「NextScreen」にスクリプトをアタッチしよう。「res://scripts/NextScreen.gd」として保存する。

「NextScreen」ノードが画面に表示されている時に、インプットマップの「ui_accept」に該当するキー(Space、Enterなど)を入力すれば、次のレベルのプレイが開始されるような仕様にしたい。

そこでまず「NextScreen.gd」のスクリプトの中身を以下のコードで置き換えよう。

extends Control


func _input(event):
	if Input.is_action_just_pressed("ui_accept"):
		yield(get_tree().create_timer(0.1), "timeout")
		hide()
		get_tree().paused = false

まず、yieldから始まる行のコードは丸ごと「GameStartView.gd」スクリプトから移動させた。これがないと、プレイヤーがスペースキーで次に進めた場合、プレイ画面に切り替わった瞬間にボールが発射されてしまうからだ。ということで、このタイミングで、「GameStartView.gd」スクリプトからはこの一行のコードをコメントアウトするか削除しておいてほしい。

hideという関数は、「CanvasItem」クラスに組み込みのメソッドである。この関数を実行すると、画面上そのノードは非表示になる。ちなみにこれは、下のGIF画像のように、シーンドックでノードの右側の「目」アイコンをクリックして閉じるのと同じ意味だ。
シーンドックで「NextScreen」の表示/非表示

get_tree().paused = falseは、プロジェクトの一時停止状態を解除するために実行する。後ほど、次のレベルの準備が終わったら「NextScreen」が最前面に表示された状態でプロジェクトを一時停止する処理を追加する。その時、インプットマップui_acceptの操作により一時停止を解除して、次のレベルをスタートできるようにした。


それでは次に「Game」ノードにスクリプトをアタッチしよう。「res://scripts/Game.gd」として保存する。
Game.gdを保存

「Game」ノードは、シーンドック上、他の全てのノードの親ノードだ。そのため、このスクリプトではノード間をまたがって処理が必要な部分をコーディングしていく。少しコード量が今までより多くなるが頑張ろう。

まずは「Game.gd」スクリプトの内容を以下のコードで置き換えてほしい。そのあと一つずつスクリプトの内容を確認していく。

extends Node2D

var level_num = 1

onready var level = $Level1
onready var next_screen = $NextScreen
onready var next_screen_level = $NextScreen/VBox/Level
onready var hud_level = $HUD/LeftBox/Level
onready var paddle = $Paddle
onready var ball = $Ball
onready var paddle_position = paddle.position
onready var ball_position = ball.position


func _ready():
	# For debug
	leave_one_brick(43)
	
	for brick in level.get_children():
		brick.connect("tree_exited", self, "_on_Brick_tree_exited")
	
	get_tree().paused = true


# For debug
func leave_one_brick(brick_num: int):
	for child in level.get_children():
		if child.get_name() == "Brick" + str(brick_num):
			continue
		child.queue_free()


# Method receiving signal
func _on_Brick_tree_exited():
	if level.get_child_count() <= 0:
		level.queue_free()
		set_next_level()


func set_next_level():
	print("set_next_level() start")
	level_num += 1
	
	# Show NextScreen node
	next_screen_level.text = "Level: " + str(level_num)
	next_screen.show()
	
	# Set Level of HUD the next level
	hud_level.text = "Level: " + str(level_num)
	
	# Set Paddle and Ball the first position
	paddle.position = paddle_position
	ball.position = ball_position
	ball.mode = 3
	
	# Set next Level node
	level = load("res://scene/Level" + str(level_num) + ".tscn").instance()
	add_child(level)
	move_child(level, 5)
	for child in level.get_children():
		child.connect("tree_exited", self, "_on_Brick_tree_exited")

	# Pause game until NextScreen is hidden
	get_tree().paused = true

それでは上から順番に見ていこう。

最初に定義しているlevel_numは単純に、現在のレベルの数値を保持する変数だ。初期値として1を入れている。

ノードが全て読み込まれてから定義すべき変数にはonreadyキーワードをつけた。これらの変数は、ノードが全て読み込まれたあとのステップ(_ready関数を実行するタイミング)で定義されるので、変数の値にget_node()get_parent()などの関数を含む場合に利用する。ちなみに変数の値の頭に$記号がついているものは、get_node()と同じ意味で、やや簡略化して記述できるので採用している。

変数hud_levelからnext_screen_levelまでは、値の示す通りそれぞれのノードを保持する。paddle_positionball_positionについては、「Paddle」ノード、「Ball」ノードの位置を保持する。

_ready関数では、コメントでも記述しているが、デバッグ用にleave_one_brick(43)という関数を実行している。これは_ready関数のすぐ下で定義している関数だ。

# For debug
func leave_one_brick(brick_num: int):
	for child in level.get_children():
		if child.get_name() == "Brick" + str(brick_num):
			continue
		child.queue_free()

その内容は、「Level1」の子ノードの名前の最後が引数で指定した43の「Brick」ノードを残し、それ以外の「Brick」ノードを全て消すという処理だ。この「Brick43」というのは配置上、パドルの初期位置からボールを発射した時に最初にボールが当たるブロックなのだ。つまり、この唯一画面に残っている「Brick43」ノードにボールで当てればゲームクリアの状態が作れるようにしている。

_ready関数内ではそのあとforループを利用して、「Level1」の子ノードである全ての「Brick–」ノードでそれぞれシグナルの接続をコードで実行している。それがbrick.connect("tree_exited", self, "_on_Brick_tree_exited")だ。第一引数にもなっているtree_exitedというシグナルはデフォルトで用意されているもので、シーンツリーから消えたら信号を発信する。selfはこの「Game.gd」スクリプトを指す。_on_Brick_tree_exitedはこのスクリプト内の関数の名前だ。この関数にシグナルを接続している。これにより、シグナルが発信されれば_on_Brick_tree_exited関数が実行される。

get_tree().paused = trueで、プロジェクトを一時停止している。これは_readyで全ての準備が整ったら、一旦一時停止状態にして、「NextScreen」ノードが非表示になるまで入力操作も受け付けなくするためだ。ただし、このままでは「NextScreen」ノードも含めて全ての処理が停止してしまう。そこで、インスペクタで「NextScreen」ノードの「Pause Mode」プロパティを「Inherit」から「Process」に変更しておこう。これにより「NextScreen」ノードだけはプロセスが続行されるようになる。
NextScreenのPause ModeをProcessに変更

# Method receiving signal
func _on_Brick_tree_exited():
	if level.get_child_count() <= 0:
		level.queue_free()
		set_next_level()

さて、シグナルを受信するメソッドがこちらの関数だ。「Level1」などの「Level」ノードの子ノード「Brick(番号)」がそれぞれボールに当たったら、消えるタイミングで、シグナルを発信し、この関数がそれを受け取る。そして、シグナルを受け取るたびに、ブロックの数がゼロになっていないかを確認している。それがif level.get_child_count() <= 0:の一行だ。つまりこの if 構文で true になるのは、最後の1つのブロックが消えた時だ。

最後のブロックが消え、if 構文が true になったら、queue_free関数によって、親の「Level」ノードそのものが消える。そしてそのあとset_next_level関数によって、次のレベルの準備作業に入る、という流れだ。ではこのset_next_level関数の中身を見ていこう。

print("set_next_level() start")は、この関数が実行されたのを確認するために用意している。

level_num += 1

これはlevel_num = level_num + 1と同じ意味である。今のレベルをクリアしたら、今現在のレベルナンバーを格納している変数level_numの値に 1 を加算し、レベルナンバーを次の数字に更新しているわけだ。

# Show NextScreen node
next_screen_level.text = "Level: " + str(level_num)
next_screen.show()

next_screen_levelという変数はこのスクリプトの冒頭で定義している「NextScreen」ノードの孫の「Level」ノードのことだ。そして、.textはその「Text」プロパティのことで画面に表示される文字列だ。その値を更新している。String 型(文字列)の値に int 型(整数)の値を格納した変数を結合したい場合、int 型はstr関数によって文字列に変換できるので、このように+記号で連結すればひと続きの文字列にすることができる。

show関数によって、非表示になっていた「NextScreen」ノードを表示している。このタイミングで次のレベルに行く前の準備画面に切り替わるわけだ。

# Set Level of HUD the next level
hud_level.text = "Level: " + str(level_num)

hud_level.textも冒頭で定義している「HUD」ノードの孫「Level」ノードの「Text」プロパティだ。その値の数字部分を次のレベルナンバーに置き換えて、HUDの表示を更新している。たくさんのコードで埋もれそうだが、これが元々このチュートリアルでやろうとしていたことだ。

# Set Paddle and Ball the first position
paddle.position = paddle_position
ball.position = ball_position
ball.mode = 3

一つのレベルをクリアしたら次のレベルに移行するまでに、パドルとボールの位置を初期位置に戻す処理をしている。.positionはそのノードの位置情報を格納するプロパティを指している。paddle_position及びball_positionはスクリプト冒頭で「Paddle」ノード、「Ball」ノードの初期位置を代入する形で定義済みだ。

ボールは「RigidBody2D」クラスのオブジェクトなので、「Mode」プロパティを「Character」から「Kinematic」に変更しないと、次のレベルが開始してパドルを動かした瞬間、どこか意図しない方向へ流れていってしまう。「Kinematic」に割り当てられている「Mode」プロパティの番号は3なので、ball.mode = 3としている。このあたりの「Ball」ノードの内容を忘れてしまった場合は、チュートリアル Part 2 を参照のこと。

# Set next Level node
level = load("res://scene/Level" + str(level_num) + ".tscn").instance()
add_child(level)
move_child(level, 5)
for child in level.get_children():
  child.connect("tree_exited", self, "_on_Brick_tree_exited")

「Level1」ノードの全ての「Brick-」ノードを消したあと「Level1」ノード自体もqueue_free関数で削除しているのえ、次の「Level2」シーンをノードとして「Game」シーンに追加する必要がある。

load関数は、シーンファイルを読み込むための関数だ。引数にファイルパスを指定すれば読み込んでくれる。先にlevel_num変数は更新済みなので、それを利用してファイル名を指定している。.instance関数は読み込んだシーンファイルのインスタンスを作る。つまり設計図から実体を作っているわけだ。それを先ほどまで「Level1」ノードを格納していた変数levelに代入している。

add_child関数により、次の「Level2」シーンをこの「Game」シーンの子ノードとして追加している。シーンドックで「Game」ノードを選択した状態でノードを新しく追加する作業と同じ意味合いだ。

move_child関数は、指定したノードを「Game」ノードの何番目の子ノードにするかを設定できる。先にadd_child関数が実行された時には、 下のスクリーンショットのように「Level2」ノードがデフォルトで一番最後の順番(ここでは 6 番目)になってしまう。つまり、このままだと次のレベルのブロックが「NextScreen」ノードより全面に表示されてしまう。
NextScreenノードより全面にLevelノード

そこでこのmove_childの第一引数に変数level(「Level2」ノード)を指定し、第二引数に5を指定して、5番目に移動しているという理屈だ。

最後に「Level2」ノードの全ての子ノード、つまり全ての「Brick-」ノードに対して、forループを利用してtree_exitedシグナルを_on_Brick_tree_exited関数に接続している。_ready関数内で最初に実行していたことと同じだ。なぜコードを使ってシグナルを接続するかというと、一つのレベルをクリアするたびに今の「Level-」ノードを削除して、次のレベルの「Level-」シーンを新たに子ノードとして追加するので、前もってノードドックから「Game」シーンのスクリプトにシグナルを接続しておくことができないからだ。

# Pause game until NextScreen is hidden
get_tree().paused = true

最後に_readyでも最後に記述していたプロジェクト全体を一時停止するのためのコードだ。次のレベルの準備が全て整ったら、プレイヤーがインプットマップ「ui_accept」の操作を行うまでは、一時停止にする。

ではプロジェクトを実行して、実際の挙動を確認してみよう。
デバッグパネルで動作確認

以下の動作に問題ないことが確認できた。

  • 次のレベル開始前に「NextScreen」ノードが表示されること
  • 「NextScreen」ノードの孫ノード「Level」(Label クラス)にレベルナンバーが反映していること
  • インプットマップ「ui_accept」のキー操作により「NextScreen」ノードが非表示になること
  • 次のレベルのプレイ画面が表示された時に、パドルとボールが初期位置に戻っていること
  • プレイ画面の「HUD」ノードの孫ノード「Level」(Label クラス)にもレベルナンバーが反映していること

現在のスコアを表示する

では次に現在のスコアを「HUD」の孫ノード「Score」に反映するように更新していこう。併せて「NextScreen」の孫ノードの方の「Score」にも同様に反映させる。また、現状、得点のシステムが全くない状態なので、ブロックを消したらポイントを獲得できるようにしていく。

では「Game.gd」スクリプトを更新していこう。

const POINT = 100 # 追加

スコア関連の定数と変数を追加した。POINTはブロックを一つ消した時に獲得できるポイントを100として定義した。これはずっと変わらないのでconstキーワードにより定数としている。

var score = 0 # 追加
var bonus_rate = 1.0 # 追加

変数scoreには一旦初期値の0を代入している。この変数には、ブロックを消して獲得したポイントを毎回加算してこれまでの合計値を保持させる。また、後ほどその値を「HUD」および「NextScreen」に反映させるように調整していく。

変数bonus_rateは、ブロックを消すたびに増加させて、ボールを落としたら初期値1.0にリセットされるようにする予定である。一つのブロックを消したときにPOINT定数の100bonus_rateを乗じた値をポイントとして獲得できるようにする。そうすることで、うまくブロックを消し続けるほど高得点が得られるようになる。ボールを落とすとbonus_rateの値を初期値にリセットさせる予定だ。

onready var next_screen_score = $NextScreen/VBox/Score # 追加
onready var hud_score = $HUD/LeftBox/Score # 追加

次にonreadyキーワード付きの変数について見ていこう。追加しているのは 2 行だけだ。どちらも特定のノードを変数として定義している。next_screen_scoreは「NextScreen」ノードの孫ノード「Score」を、hud_scoreは「HUD」ノードの孫ノード「Score」をそれぞれ代入している。

# Method receiving signal
func _on_Brick_tree_exited():
	# Update Score
	score += POINT * bonus_rate # 追加
	bonus_rate += 0.1 # 追加
	hud_score.text = "Level: " + str(score) # 追加
	
	# メソッド内の以下省略

_on_Brick_tree_exitedにいくつか処理を追加する。この関数はブロックが消された時に「Brick-」ノードからtree_exitedシグナルを受信するメソッドであることを思い出してほしい。今回、ブロックを消した時の処理に以下の3つを追加した形だ。

  • score += POINT * bonus_rateにより、変数scoreの値にボーナス率であるbonus_rateを乗じた値がポイントとして加算される。
  • bonus_rate += 0.1は、その時のbonus_rateの値に0.1が加算する、という意味だ。なお、このアップデートされたボーナス率は次にブロックを消した時に利用される。
  • hud_score.text = "Level: " + str(score) は、常に HUD の表示を更新するためのコードだ。「HUD」の孫ノード「Score」(Labelクラス)の「Text」プロパティの値として、ブロックが消されるたびにその時の変数scoreの値を代入している。
func set_next_level():
	print("set_next_level() start")
	level_num += 1
	
	# Show NextScreen node
	next_screen_level.text = "Level: " + str(level_num)
	next_screen_score.text = "Score: " + str(score) # 追加
	next_screen.show()
  
  # メソッド内の以下省略

set_next_level関数にも 1 行だけコードを追加した。next_screen_score.text = "Score: " + str(score)だ。これは「NextScreen」ノードの孫ノード「Score」(Labelクラス)の「Text」プロパティに「Score: (現在の変数scoreの値)」という文字列を代入している。これにより、次のレベルの準備画面に現在のスコアが表示されるようになるはずだ。

ではまず、ブロックを消したら HUD のスコア表示が更新されるか確認してみよう。「Level1」ノードのブロックを全て表示してテストするため、_ready内のleave_one_brickの行をコメントアウトしてから、プロジェクトを実行する。

func _ready():
	# For debug
	#leave_one_brick(43) #コメントアウト

デバッグパネルでHUDのスコア動作確認

ブロックを消すたびに HUD のスコアにボーナス込みのポイントが加算されているのが確認できた。

今度は次のレベルの前の準備画面「NextScreen」にスコアが反映するかを確認する。さっきコメントアウトした_ready内のleave_one_brickの行をアクティブにしてから、プロジェクトを実行して確認してみよう。

func _ready():
	# For debug
	leave_one_brick(43)

デバッグパネルでNextScreenのスコア動作確認

最後の1つを残して他のブロックを消した状態からスタートしているので、かなり高得点からの開始だが、「NextScreen」には問題なく変数scoreの値が反映されたことが確認できた。

それでは一旦ここまでに更新した「Game.gd」スクリプト全体を確認しておこう。

extends Node2D

const POINT = 100

var level_num = 1
var score = 0
var bonus_rate = 1.0

onready var level = $Level1
onready var next_screen = $NextScreen
onready var next_screen_level = $NextScreen/VBox/Level
onready var next_screen_score = $NextScreen/VBox/Score
onready var hud_level = $HUD/LeftBox/Level
onready var hud_score = $HUD/LeftBox/Score
onready var paddle = $Paddle
onready var ball = $Ball

onready var paddle_position = paddle.position
onready var ball_position = ball.position


func _ready():
	# For debug
	leave_one_brick(43)
	
	for brick in level.get_children():
		brick.connect("tree_exited", self, "_on_Brick_tree_exited")

	get_tree().paused = true


# For debug
func leave_one_brick(brick_num: int):
	for child in level.get_children():
		if child.get_name() == "Brick" + str(brick_num):
			continue
		child.queue_free()


# Method receiving signal
func _on_Brick_tree_exited():
	# Update Score
	score += POINT * bonus_rate
	bonus_rate += 0.1
	hud_score.text = "Score: " + str(score)
	
	# Exit current Level node
	print("get child count: ", level.get_child_count())
	if level.get_child_count() <= 0:
		level.queue_free()
		print("level queue free")
		set_next_level()


func set_next_level():
	print("set_next_level() start")
	level_num += 1
	
	# Show NextScreen node
	next_screen_level.text = "Level: " + str(level_num)
	next_screen_score.text = "Score: " + str(score)
	next_screen.show()
	
	# Set Level of HUD the next level
	hud_level.text = "Level: " + str(level_num)
	
	# Set Paddle and Ball the first position
	paddle.position = paddle_position
	ball.position = ball_position
	ball.mode = 3
	
	# Set next Level node
	level = load("res://scene/Level" + str(level_num) + ".tscn").instance()
	add_child(level)
	move_child(level, 5)
	for child in level.get_children():
		child.connect("tree_exited", self, "_on_Brick_tree_exited")

	# Pause game until NextScreen is hidden
	get_tree().paused = true

現在のライフを表示する

それでは最後に HUD の最後のセクションであるライフの表示に関わる更新していく。必要な作業は以下の通りだ。

  • 「Ball」ノードをシーンとして保存する
  • 「Ball.gd」スクリプトを更新する
  • 「Game.gd」スクリプトを更新する

「Ball」ノードをシーンとして保存する

Ballが画面下に落ちたらライフが一つ減る、という仕組みにしたい。この仕組みを成立させるためには、ボールが下に落ちたら「Ball」ノードを削除して、新しい「Ball」ノードを追加する必要がある。「Ball」ノードを追加するには、現在「Game」ルートノードの子である「Ball」ノードをシーンとして保存し、毎回そのインスタンスを新しいノードとして追加するのが効率的だ。

シーンドックで「Game」シーンの「Ball」ノードを右クリックし、「ブランチをシーンとして保存」を選択しよう。
ブランチをシーンとして保存

「res://scene/Ball.tscn」として保存すれば「Ball」シーンの完成だ。


「Ball.gd」スクリプトを更新する

この流れで、先に「Ball」ノードにアタッチしているスクリプト「Ball.gd」の方を先に更新しておく。

func _on_VisibilityNotifier2D_screen_exited():
	queue_free()
	#get_tree().change_scene("res://scene/GameOverView.tscn") # 不要

「Ball.gd」スクリプトの一番最後にあるfunc _on_VisibilityNotifier2D_screen_exited():のブロックの中を修正する。ゲームオーバー画面へシーンを切り替えるコードget_tree().change_scene("res://scene/GameOverView.tscn") を削除、またはコメントアウトするだけだ。理由は、ゲームオーバーになるタイミングが、今までは1回ボールが画面下に落ちた時だったが、これからはライフがゼロになった時に変わるからだ。その部分のコードは「Game.gd」スクリプトの方に記述していくので、こちらの更新はこれだけだ。


「Game.gd」スクリプトを更新する

では「Game.gd」スクリプトの更新内容を上から順番に確認していこう。

var life = 3

変数lifeの初期値を3として定義した。この値と「HUD」ノードの孫ノード「Life-」の表示数とが一致するようにしていく。「Life-」の数は全部で5個あるが、のちのちライフアップのアイテムも検討しているのと、5だと易しすぎるので3とした。

onready var next_screen_life = $NextScreen/VBox/HBoxContainer/Life
onready var hud_rightbox = $HUD/RightBox

onreadyキーワード付きの変数を2つ追加した。next_screen_lifeは「NextScreen」ノードの曽孫ノード「Life」を、hud_rightboxは「HUD」ノードの子ノード「RightBox」をそれぞれ指している。

func _ready():
	# For debug
	leave_one_brick(43)
	update_hud_life() # 追加
	ball.connect("tree_exited", self, "_on_Ball_tree_exited") # 追加
	
	for brick in level.get_children():
		brick.connect("tree_exited", self, "_on_Brick_tree_exited")

_readyのブロック内にいくつか処理を追加した。まずupdate_hud_lifeという関数を追加したが、この関数の内容はあとで説明する。

ball.connect("tree_exited", self, "_on_Ball_tree_exited") は「Ball」ノードのtree_exitedシグナルをこのスクリプトの_on_Ball_tree_exitedという関数に接続している。tree_exitedはたびたび使用しているが、そのノードがシーンツリーから削除された時に発信されるシグナルだ。ボールが画面下に落ちて、シーンツリーから消えたタイミングでライフを更新するために絶対に必要なシグナルなのだ。

# Method receiving Ball signal
func _on_Ball_tree_exited():
	life -= 1
	update_hud_life()
	
	if life <= 0:
		get_tree().change_scene("res://scene/GameOverView.tscn")
	else:
		paddle.position = paddle_position
		ball = load("res://scene/Ball.tscn").instance()
		add_child(ball)
		move_child(ball, 3)
		ball.connect("tree_exited", self, "_on_Ball_tree_exited")

先程のシグナルtree_exitedの接続先のメソッドがこの_on_Ball_tree_exitedだ。ボールが画面下に落ちて消えたタイミングで実行される。

ボールが画面下に落ちた時、ライフを一つ減らしたいので、まず変数lifeから- 1している。その次にupdate_hud_lifeという関数を実行して、ライフに関わるアップデートをしているが、その処理内容について詳しくはのちほど説明する。

if life <= 0:のブロックでは、ライフがゼロの場合、get_tree().change_scene("res://scene/GameOverView.tscn")でゲームオーバー画面に遷移するようにしている。このコードは元々「Ball.gd」スクリプトにあったものだが、ゲームオーバーの条件が変わったため、こちらに移動させた。

次にelse:ブロックだ。

ボールが画面下に落ちたら、パドルも一旦初期位置に戻したいのでpaddle.position = paddle_positionとしている。

ボールが画面下に落ちて「Ball」ノードが消えたら、新しい「Ball」ノードを用意する必要がある。そのために、まず「Ball.tscn」シーンファイルを読み込み、そのインスタンス作って、変数ballに代入している。そのあとadd_childで、その新しい「Ball」インスタンスを「Game」ルートノードの子ノードとして追加している。そしてさらにmove_childで、「Ball」ノードの順序を最後尾から最初と同じ3番目に移動させている。最後に改めて、新しい「Ball」ノードのtree_exitedシグナルを_on_Ball_tree_exitedメソッドに接続している。

# Set life nodes shown and hidden as life variable
func update_hud_life():
	var count = 0
	for child in hud_rightbox.get_children():
		count += 1
		if count <= life:
			child.show()
		else:
			child.hide()

update_hud_lifeという関数を新たに追加した。_ready内で実行されていたものだ。

これは HUD のライフ表示を更新するためのメソッドだ。forループ内では、「HUD」ノードの子ノード「RightBox」にぶら下がっている全ての子ノード、つまり全ての「Life-」ノードに対して処理を実行している。その処理というのは、「Life-」ノードの「RightBox」ノード中の順番が、変数lifeの値以下だったら表示し、そうでなければ非表示にする、というものだ。例えば、変数lifeの値が2になったら、「Life1」、「Life2」までは表示し、「Life3」、「Life4」、「Life5」は非表示にする、ということになる。

func set_next_level():
	print("set_next_level() start")
	level_num += 1
	
	# Show NextScreen node
	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_next_level内のブロックだ。next_screen_life.text = "x " + str(life) の一行を追加した。内容は「NextScreen」ノードの「Life」ノードの「Text」プロパティの値(文字列)に変数lifeの値を反映させる、というものだ。

これで HUD のライフに関わる更新ができたはずだ。実際にプロジェクトを実行して確認してみよう。
デバッグパネルでライフの挙動を確認

以下の動作に問題がないことを確認できた。

  • 「NextScreen」に現在のライフが表示される
  • HUD のライフがボールを画面下に落とすたびに減る
  • HUD のライフがゼロになったらゲームオーバー画面に遷移する

最終的に「Game.gd」のスクリプト全体はこのようになっている。うまく動作しなかった方はご自身で作ったスクリプトと見比べてみて欲しい。

extends Node2D

const POINT = 100

var level_num = 1
var score = 0
var bonus_rate = 1.0
var life = 3

onready var level = $Level1
onready var next_screen = $NextScreen
onready var next_screen_level = $NextScreen/VBox/Level
onready var next_screen_score = $NextScreen/VBox/Score
onready var next_screen_life = $NextScreen/VBox/HBox/Life
onready var hud_level = $HUD/LeftBox/Level
onready var hud_score = $HUD/LeftBox/Score
onready var hud_rightbox = $HUD/RightBox
onready var paddle = $Paddle
onready var ball = $Ball

onready var paddle_position = paddle.position
onready var ball_position = ball.position


func _ready():
	# For debug
	leave_one_brick(43)
	update_hud_life()
	ball.connect("tree_exited", self, "_on_Ball_tree_exited")
	
	for brick in level.get_children():
		brick.connect("tree_exited", self, "_on_Brick_tree_exited")
	
	get_tree().paused = true

# For debug
func leave_one_brick(brick_num: int):
	for child in level.get_children():
		if child.get_name() == "Brick" + str(brick_num):
			continue
		child.queue_free()


# Method receiving Ball signal
func _on_Ball_tree_exited():
	life -= 1
	update_hud_life()
	
	if life <= 0:
		get_tree().change_scene("res://scene/GameOverView.tscn")
	else:
		paddle.position = paddle_position
		ball = load("res://scene/Ball.tscn").instance()
		add_child(ball)
		move_child(ball, 3)
		ball.connect("tree_exited", self, "_on_Ball_tree_exited")


# Set life nodes shown and hidden as life variable
func update_hud_life():
	var count = 0
	for child in hud_rightbox.get_children():
		count += 1
		if count <= life:
			child.show()
		else:
			child.hide()


# Method receiving Brick signal
func _on_Brick_tree_exited():
	# Update Score
	score += POINT * bonus_rate
	bonus_rate += 0.1
	hud_score.text = "Score: " + str(score)
	
	# Exit current Level node
	print("get child count: ", level.get_child_count())
	if level.get_child_count() <= 0:
		level.queue_free()
		print("level queue free")
		set_next_level()


func set_next_level():
	print("set_next_level() start")
	level_num += 1
	
	# Show NextScreen node
	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 Paddle and Ball the first position
	paddle.position = paddle_position
	ball.position = ball_position
	ball.mode = 3
	
	# Set next Level node
	level = load("res://scene/Level" + str(level_num) + ".tscn").instance()
	add_child(level)
	move_child(level, 5)
	for child in level.get_children():
		child.connect("tree_exited", self, "_on_Brick_tree_exited")
	
	# Pause game until NextScreen is hidden
	get_tree().paused = true


おわりに

以上で Part 7 は完了だ。かなり長くなってしまったが、最後までうまく進められただろうか。今回行ったブロック崩しの更新内容をまとめておく。

  • HUD に必要なノードを追加した
  • 次のレベルの準備画面「NextScreen」に必要なノードを追加した
  • 主に「Game.gd」スクリプトによってゲームの状況を HUD と NextScreen に連動させた

次回 Part 8 ではさらにプレイ中のポーズ画面とポーズ機能を追加していく予定だ。