第8回目の今回は、HUD を実装していく。

HUD というのは、ヘッズアップディスプレイ(Heads Up Display)の略で、ゲームプレイ中に常に画面上に表示されている UI の一つだ。例えば、プレイヤーの残りのライフ(海外での呼称 Health に合わせてこれ以降はヘルスと呼ぶ)や、獲得したスコアなどがわかりやすいだろう。HUD を実装する目的は現在のゲームの状態を視覚的にわかりやすくすることだ。

このチュートリアルでは、プレイヤーキャラクターのヘルスとスコアと現在のレベルを画面の上部に表示する HUD を作っていく。プレイヤーキャラクターがダメージを受ける仕組みが未実装なので、その仕組みも併せて追加していく。

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



フォントのアセットをファイルシステムに追加する

HUD を作るにあたって、HUD 内に表示する文字のフォントもできればレトロゲーム風なものにしたいところだ。

まずは Godot のアセットライブラリにアクセスして使えそうなフォントアセットをダウンロードしよう。Godot Engine エディタ上部の「AssetLib」からアクセス可能だ。

アクセスできたら検索ボックスに「font」と入力して検索してみよう。すると「Open Font Package」というアセットが見つかるはずだ(2022/03/05 現在)。
AssetLib

見つかったらそれをクリックしてダウンロードする。
フォントアセットをダウンロード

この時、おそらく「icon.png」が競合していると警告される。競合するファイルはインストールする前にチェックを外しておこう。
競合は対象から外す

インストールが完了したら OK をクリックする。
インストール完了でOK

ファイルシステムを覗いてみると、ダウンロードしたフォントアセットが見つかるはずだ。
ファイルシステムにフォントがある



HUD シーンを作る

新規シーンを作成して必要なノードを追加する

まずは新規で HUD のシーンを作ろう。

  1. 「シーン」メニュー>「新規シーン」を選択する
  2. 「ルートノードの生成」で「ユーザーインターフェース」を選択し、「Control」クラスのルートノードにし、名前を「HUD」に変更する
  3. 「HUD」に「HBoxContainer」を追加し、名前を「HUDHBox」にする
  4. 「HUDHBox」に「HBoxContainer」ノードを追加し、名前を「HealthHBox」にする
  5. 「HealthHBox」に「Label」ノードを追加し、名前を「HealthText」にする
  6. 「HealthHBox」に「TextureProgress」ノードを追加し、名前を「HealthBar」にする
  7. 「HUDHBox」に「Label」ノードを追加し、名前を「ScoreText」にする
  8. 「HUDHBox」に「HBoxContainer」ノードを追加し、名前を「LevelHBox」にする
  9. 「LevelHBox」に「Label」ノードを追加し、名前を「LevelText」にする
  10. 「LevelHBox」に「TextureRect」ノードを追加し、名前を「LevelTexture」にする
  11. 「UI」フォルダを作成し、ファイルパスを「res://UI/HUD.tscn」としてシーンを保存する

シーンツリーに必要なノードは揃ったので、次はそれぞれのノードのプロパティを編集する。
HUDシーンツリー

新しく登場するノードがあるので、それぞれ簡単に用途を説明しておこう。公式オンラインドキュメントのそれぞれのクラスの説明ページも必要に応じて確認いただきたい。

「Control」クラスは全てのユーザーインターフェース(UI)系のクラス( こういう緑色のアイコンのクラス)の継承元になっている基本のクラスだ。今回作成したシーンではルートノードとして利用したが、これは単に、子ノードが全てUI系のノードのため、不都合のない入れ物として使っているだけだ。

公式オンラインドキュメント
Control

「HBoxContainer」クラスの"H"は"Horizontal"の頭文字から来ており、その名の通り、水平方向に子ノードを並べて表示するための収納箱(コンテナ)のような役割をする。今回、HUD の要素は画面上部に横並びにしたかったので、このクラスのノードを利用している。お察しかもしれないが垂直方向(Vertical)用に「VBoxContainer」クラスもある。

公式オンラインドキュメント
HBoxContainer
VBoxContainer

「Label」クラスは、UIに文字列を表示させたい時に利用する。このクラスは、シンプルに文字列にフォントファイルを適用して表示するだけのシンプルなものだ。リッチテキストを表示したい場合は「RichTextLabel」という別のクラスを利用する。このチュートリアルでは、「HEALTH」、「SCORE」、「LEVEL」という文字を HUD に表示するために利用する。

公式オンラインドキュメント
Label
RichTextLabel

「TextureProgress」クラスは、プログレスバー系のクラスの一つで、割り当てたテクスチャを利用してプログレスバーを作成することができる。このチュートリアルではヘルスの状態を直感的にわかりやすく表示するのに利用する。これは割と一般的な使い方だ。別の「ProgressBar」というクラスもあるが、これはどちらかというとデータ読み込み中などに利用するものだ。

公式オンラインドキュメント
TextureProgress
ProgressBar

「TextureRect」クラスは、四角い図形にテクスチャを割り当てて表示するクラスで、“Rect"は"Rectangle"の略だ。もちろんテクスチャの画像が透過部分のある PNG イメージなら、四角に限らずあらゆるイメージを表示できる。用途としては、テクスチャで画面全体を覆ってゲームの背景に利用することもあれば、HUD やインベントリシステムなど UI の一部のイメージとして使用したり、HUD の範囲だけの背景として使われることもあり、様々な場面で役に立つクラスだ。今回は、現在のレベルを示す数字を画像で表示するために利用する(元々用意したアセットにレベルの数字の画像があり、せっかくなので)。

公式オンラインドキュメント
TextureRect


各ノードのプロパティを編集する

HUD ルートノード

「HUD」ルートノードを選択したら、ツールバーで「レイアウト」>「Rect全面」を選択して、画面一杯に広げる
Rect全面


HUDHBox ノード

  1. ツールバーで「レイアウト」>「上伸長」を選択して、画面上部で横一杯に広げる
  2. 「HUDHBox」を選択してインスペクターから「Alignment」プロパティを「Center」にする
  3. 「Margin」プロパティを(Left: 8, Top: 8, Right: -8, Bottom: 0)にして画面端に少し余白を作る
  4. 「Theme Overrides」>「Constants」>「Separation」プロパティを 16 にする
    HUDHBoxのインスペクター

HealthHBox ノード

このノードのプロパティは編集不要だ。


HealthText ノード

  1. 「HealthText」ノードを選択して、インスペクターから「Text」プロパティに「health」と入力する
  2. 「Uppercase」プロパティをオンにする
    HealthTextプロパティ
  3. 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」(事前に準備したフォント)をドラッグ&ドロップして適用する。
  4. 追加したフォントをクリックし、「Settings」>「Size」プロパティを 16 にする
  5. さらに「Extra Spacing」>「Top」プロパティを -8 にする
    HealthTextプロパティ

HealthBar ノード

  1. 「HealthBar」ノードを選択したら、インスペクターで「Nine Patch Stretch」をオンにして、テクスチャのサイズを柔軟に変更できるようにする
    HealthBarプロパティ
  2. 「Textures」>「Under」にファイルシステムから「res://Assets/Background/Pink.png」をドラッグ&ドロップして適用する(ヘルスバー用のアセットがないのであり物でやりくりするのだ)
  3. 今回「Textures」>「Over」プロパティは設定せずそのまま
  4. 同様に「Textures」>「Progress」プロパティにファイルシステムから「res://Assets/Background/Green.png」をドラッグ&ドロップして適用する
  5. テクスチャの柄が目立たないように「Tint」>「Under」プロパティの値を # 000000 にして大幅に色を変える
  6. 同様に「Tint」>「Progress」プロパティの値を # 26ab3c にする
    HealthBarプロパティ
  7. 「Range」>「Value」プロパティの値を 100 にする(この値に連動して「Progress」のテクスチャサイズが変化する)
    HealthBarプロパティ
  8. 「Rect」>「Min Size」プロパティの x の値を 100 にする
    HealthBarプロパティ

ScoreText ノード

「HealthText」ノードと同様の手順だ。

  1. 「ScoreText」ノードを選択したら、インスペクターで「Text」プロパティに「score 0」と入力する
  2. 「Uppercase」プロパティをオンにする
  3. 「Size Flags」>「Horizontal」で「Fill」と「Expand」にチェックを入れる
    ScoreTextプロパティ
  4. 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」をドラッグ&ドロップして適用する。

LevelHBox ノード

このノードのプロパティは編集不要だ。


LevelText ノード

「HealthText」ノードや「ScoreText」と同様の手順だ。

  1. 「LevelText」ノードを選択したら、インスペクターで「Text」プロパティに「level」と入力する
  2. 「Uppercase」プロパティをオンにする
  3. 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」をドラッグ&ドロップして適用する。

LevelTexture ノード

「LevelTexture」ノードを選択したら、インスペクターで「Texture」プロパティに、ファイルシステムから「res://Assets/Menu/Levels/01.png」をドラッグ&ドロップして適用する
LevelTextureプロパティ

ちなみに画像にブラーがかかっていたら、インポートドックで Pixel 2D のプリセットを適用して再インポートしておこう。



HUD シーンをインスタンス化して Game シーンに追加する

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

  1. まずは「Game.tscn」を開く
  2. 「Game」ルートノードに「CanvasLayer」ノードを追加し、名前を「HUDLayer」に変更する
  3. 「HUDLayer」ノードに「HUD.tscn」シーンをインスタンス化して追加する

シーンツリーが以下のようになればOKだ。
GameにHUDを追加

ちなみに「CanvasLayer」クラスのノードを追加した理由は、このゲームのカメラを担当する「Camera2D」ノードとはレイヤーを別ける必要があったからだ。そうしないと、プレイヤキャラクターを操作するやいなや、HUDの位置がズレていってしまう。「CanvasLayer」の子として「HUD」ノードを追加することで、HUD がカメラとは別レイヤーになり、常に画面上の指定の位置に HUD が固定された状態を維持できるのだ。

では実際にプロジェクトを実行して HUD の表示を確認してみよう。

プロジェクトを実行してHUDの確認

プレイヤーキャラクターが移動しても HUD は画面上部に固定されたままで、問題なさそうだ。小休止したら次のスクリプトの手順に進もう。



HUD ノードにスクリプトをアタッチする

「HUD」ルートノードにスクリプトをアタッチしよう。ファイルパスを「res://UI/HUD.gd」として作成する。スクリプトエディタを開いたら、以下のコードを記述してほしい。

extends Control

# 値の変更が必要なプロパティをもつノードの参照
onready var health_bar = $HUDHBox/HealthHBox/HealthBar
onready var score_text = $HUDHBox/ScoreText
onready var level_texture = $HUDHBox/LevelHBox/LevelTexture


# ゲーム開始時にヘルスバーを満タンにする
func _ready():
	health_bar.value = 100

# 以下の3つのこのメソッドは Game.gd から呼び出す予定

# HealthBar ノードの Value プロパティに引数 health の値を適用する
func update_health(health):
	health_bar.value = health

# ScoreText ノードの Text プロパティに引数 score の値を文字列に変換して適用する
func update_score(score):
	score_text.text = "score " + str(score)

# LevelTexture ノードの Texture プロパティに現在のレベル数と同じテクスチャ画像を適用する
func update_level(level):
	# 文字列型のレベル数を格納するための変数を定義
	var str_level
	# レベルが 10 未満だったら
	if level < 10:
		# 頭に 0 をつけた文字列型のレベル数表記に変換
		str_level = "0" + str(level)
	# レベルが 10 以上 50 以下だったら(アセットが 50 までしかないため)
	elif level <= 50:
		# そのまま文字列型のレベル数表記に変換
		str_level = str(level)
	# アセットのファイル名の数字の部分に str_level 変数の値を利用してテクスチャファイル読み込み
	var file = load("res://Assets/Menu/Levels/" + str_level + ".png")
	# LevelTexture ノードの Texture プロパティに読み込んだテクスチャを適用
	level_texture.texture = file

上記「HUD.gd」スクリプトでは、値の変更が発生するプロパティを持つノードを参照するプロパティを3つ定義した。また、それらのプロパティを更新するためのメソッドもそれぞれ作成した。これら3つのメソッドは「Game.gd」スクリプト側から呼び出すことになる。理由は、「Game.gd」の方で、プレイヤーキャラクターのヘルス、スコア、および現在のレベル数を管理したいからだ。

さて、ここから複数のノードのスクリプトが絡み合っていくのでちょっとややこしいかもしれない。こういう時は図を書くとわかりやすい。紙と鉛筆で良いので、以下のような図を書いて頭の中を整理してからコーディング作業を開始するのがおすすめだ。ちなみにこの図はhealthおよびscoreプロパティの値が最終的に HUD へ反映するまでの流れを示している。自分の頭が整理できさえすれば細かい図のクオリティを気にする必要はない(筆者も普段は鉛筆で大雑把に書く)。
図

ではこの流れでまずは「Game.gd」スクリプトを編集していこう。「Game.gd」を開いたら、冒頭の_readyメソッドまでのコードを以下の内容に更新する。「# 追加」とコメントしているところが更新箇所だ。

extends Node

# 現在のヘルスを格納するプロパティ
var health: float = 100.0 # 追加
# 現在のスコアを格納するプロパティ
var score: int = 0 # 追加

var level: Node2D
# Player ノードの参照(予定)
var player: KinematicBody2D # 追加

export var current_level = 1
export var final_level = 2
# HUD ノードの参照
onready var hud = $HUDLayer/HUD # 追加


func _ready():
	add_level()
	# HUD ノードの update_health メソッドの引数に health プロパティの値を渡して実行
	hud.update_health(health) # 追加
	# HUD ノードの update_score メソッドの引数に score プロパティの値を渡して実行
	hud.update_score(score) # 追加
	# HUD ノードの update_level メソッドの引数に current_level プロパティの値を渡して実行
	hud.update_level(current_level) # 追加

# 以下省略

今回「Game.gd」スクリプト内で、プレイヤーキャラクターのヘルスの管理用にhealthプロパティを、スコアの管理用にscoreプロパティを新たに定義した。

そして、先に「HUD.gd」で定義した3つのメソッドも、さっそく「Game.gd」の_readyメソッドの中で実行している。これにより、ゲーム開始時にhealthscorelevelの3つのプロパティの初期値が HUD に反映される。



HUD にプレイヤーキャラクターが受けたダメージを反映させる

ここからは、プレイヤーのヘルス管理を実装していく。

Player シーンに HitBox を追加する

長らく放置していた、プレイヤーキャラクターのダメージを受ける仕組みを作る時が来た。敵に当たった時にシグナルを発信させてダメージ処理を行いたいので、敵キャラクターと同様に、下記手順でプレイヤーキャラクターにも「HitBox」を作成するところから始めていこう。

  1. 「Player.tscn」を開く
  2. 「Player」ルートノードに「Area2D」ノードを追加し、名前を「HitBox」に変更する
  3. 「HitBox」ノードに「CollisionShape2D」ノードを追加する
    Playerのシーンツリー
  4. 追加した「CollisionShape2D」の「Shape」プロパティに「新規RectangleShape2D」を設定する。
  5. 追加した「CollisionShape2D」のコリジョン形状を編集する。「Player」直下の「CollisionShape2D」の形状より横幅が 1 px だけ大きくなるようにし、足元は敵キャラクターを踏む際にダメージを受けないように少し空けておく。関連プロパティは以下の値になった。
    • Extents: (7, 8.5)
    • Position: (0, 3.5)
      コリジョン形状の編集

スクリプトでプレイヤーキャラクターが敵キャラクターに当たった時のダメージ処理を実装をする

Player.gd

プレイヤーキャラクターが敵キャラクターに当たった時の処理をコーディングしていく。「Player.gd」スクリプトを開こう。

まずは、プレイヤーが敵キャラクターに当たって、ダメージを受けた瞬間に HUD へ反映させるためには、シグナルが必要だ。残念ながら「Player」ルートノードのクラスである「KinematicBody2D」には、「Area2D」クラスの「body_entered(body)」のようなシグナルがない。そこで、ひとまず自分でシグナルを定義する。

extends KinematicBody2D

signal enemy_hit(damage) # 追加

# 以下省略

これでenemy_hitというシグナルが定義できた。(damage)というふうにシグナルに引数を定義しておくことで、シグナルを発信したときにこの引数の値を、接続先のメソッドの引数に渡すことができる。つまり、この引数damageに敵キャラクターから受けたダメージを入れてシグナルを発信し、シグナルの接続先メソッドへダメージの値を渡すことができるということだ。

次に、先ほど追加した「HitBox」ノードのシグナルを接続する。「HitBox」ノードの「body_entered(body)」シグナルを「Player.gd」スクリプトに接続しよう。
HitBoxのシグナルを接続

すると、「Player.gd」スクリプトに、_on_HitBox_body_enteredメソッドが追加されたはずだ。このメソッド内で、先に定義したenemy_hitシグナルを発信させる。具体的には以下のようにコードを更新しよう。

# プレイヤーキャラクターに物理ボディが当たったら呼ばれるメソッド
func _on_HitBox_body_entered(body): # 追加
	# もし当たったのが敵キャラクターだったら
	if body.is_in_group("Enemies"):
		# デバッグ用
		print("Enemy hit player. Damage is ", body.damage)
		# enemy_hit シグナルを発信する(敵キャラクターから受けるダメージの damage プロパティを引数に渡す)
		emit_signal("enemy_hit", body.damage)
		# AnimatedSprite の hit アニメーションを再生する
		sprite.play("hit")

Enemy.gd

さて上のスクリプトで先に登場している「Enemy」ノードのdamageプロパティだが、これはまだ定義していないので、さっそく「Enemy.gd」を開いて定義しよう。

extends KinematicBody2D


export var gravity: int
export var speed: int
export var damage: float # Added @ 追加

# 以下省略

値は何も入れずに、型だけ小数点を含む数値のfloatとして定義しておこう。exportキーワードを追加したので、「Enemy.tscn」を継承するそれぞれの敵キャラクターのシーンで、当たったときに受けるダメージの値をインスペクター上で設定する予定だ。

ところで、なぜdamageプロパティの型を整数のint型で定義しないのかというと、このdamageの値を「Game」ノードのfloat型で定義したhealthの値から減算することになるのだが、そのときに型が異なるとエラーになるからだ。ではなぜhealthプロパティもfloat型に定義したかというと、最終的にこの値を割り当てる先がfloat型の値をとる「HUD」シーンの「HealthBar」ノードの「Value」プロパティだからだ。

プログラムで何らかの計算をさせるのに型を揃えるのは、プログラミング全般で共通のルールなので、この機会に覚えておこう。


Chameleon.gd

ここで気をつけておきたい一つ目のポイントは、カメレオンだ。この敵キャラクターは舌を伸ばして攻撃してくる。この舌に当たるとプレイヤーはダメージを受けなければならないが、この舌の部分は「Chameleon」ルートノード(KinematicBody2Dクラス)直下の「CollisionShape2D」のコリジョン形状とは重なっておらず、当たっても物理ボディとは判定されない。この場合は、「Chameleon.gd」スクリプト側で「RayCast2D」ノードの衝突判定を利用して「Player」のenemy_hitシグナルを発動させる。では「Chameleon.gd」を開こう。

更新は2箇所、ステータス管理用のプロパティを一つ追加することと、スクリプトの一番最後で定義しているattackメソッドを更新することだ。attackメソッド内の最後に追加したifブロック丸ごと追加している(「# 追加」とコメントしている行が更新箇所だ)。

extends "res://Enemies/Enemy.gd"

# 舌の当たり判定が有効かどうか(初期値は有効)
var tongue_hit_enabled = true # 追加

# 中略

func attack():
	sprite.play("attack")
	if sprite.frame == 6 or sprite.frame == 7:
		if raycast.is_colliding() and raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) < 50:
				# 舌の当たり判定が有効な場合は
				if tongue_hit_enabled: # 追加
					# デバッグ用
					print("Chameleon's tongue hits player.")
					# Player の enemy_hit シグナルを damage プロパティ付きで発信
					raycast.get_collider().emit_signal("enemy_hit", damage) # 追加
					# 舌の当たり判定を無効にする
					tongue_hit_enabled = false # 追加
					# 0.83秒(attack アニメーションの約1回分の長さ)待つ
					yield(get_tree().create_timer(0.83), "timeout") # 追加
					# 舌の当たり判定を有効にする
					tongue_hit_enabled = true # 追加

今までのコードのままだと、_physics_processメソッド内でのattackメソッドが 60 FPS に合わせて 1 秒間に 60 回呼ばれる。つまり、1秒間に 60 回のペースで舌への当たり判定が発生し、プレイヤーのヘルスは一瞬で 0 になってしまう。

これを避けるために、舌の当たり判定のステータス管理用にtongue_hit_enabledというステータスを用意した。初期値はtrueで、その時は当たり判定が有効だ。この状態で、カメレオンの舌が一度プレイヤーキャラクターに当たると、「Chameleon.gd」スクリプト側から「Player」のenemy_hitシグナルが発信される。その後、直ちに舌の当たり判定をfalseにする。さらにそこから、カメレオンの「attack」アニメーション一周分のおおよその所要時間である 0.83 秒待機したら、また舌の当たり判定を有効にする。これにより、1回の舌を伸ばすアニメーションにつき1回しかダメージを受けない仕組みを作った。


Seed.gd

そして、もう一つ忘れてはいけないのが、プラントという敵キャラクターは種を飛ばしてくる。この種のシーンである「Seed.tscn」は「Enemy.tscn」を継承していないので、別途damageプロパティを定義しておく必要がある。また、「Enemies」グループに属しておらず、ルートノードが「Area2D」クラスであり物理ボディでもないので、ここで諸々の修正を行っておこう。

では「Seed.tscn」を開いてほしい。まずは「Seed」ノードを「Enemies」グループに追加しよう。
SeedをEnemiesグループに追加

シーンドックで「Seed」ルートノードを右クリックし、「型を変更」を選択する。そして「StaticBody2D」を選択しよう。これで種も物理ボディになった。
Seedの型を変更

しかし、スクリプトの方はまだ「Area2D」を継承した形になっているので、修正する。「Seed.gd」スクリプトを開き、以下のように更新しよう。ここでついでにdamageプロパティも定義しておく。

#extends Area2D # 削除
extends StaticBody2D # 追加


export var speed = 150
export var damage: float = 32 # 追加

「StaticBody2D」ノードにはbody_entered(body)シグナルがないので、_on_Seed_body_enteredメソッドはひとまず削除しておこう。

#func _on_Seed_body_entered(body): # 削除
#	if body.name == "Player":
#		print("Seed hits player.")
#	queue_free()

しかし、このままでは種がプレイヤーキャラクターに当たっても消えず、そのまま物理ボディ同士の衝突により、押し続けてくる。そこで、子ノードとして改めて「Area2D」ノードを加え、物理ボディとの接触により種が解放されるようにする。

まずはシーンドックで「Seed」ルートノードに「Area2D」ノードを追加。さらに「Area2D」ノードに「CollisionShape2D」ノードを追加する。
シーンドックでSeedにArea2D追加

「CollisionShape2D」のコリジョン形状を調整する。「Shape」プロパティに「新規 CircleShape2D」を割り当てる。2Dワークスペースで、「Seed」ルートノード直下の「CollisionShape2D」の形状より 1 px 大きめに設定した。「Radius」プロパティは 5 だ。
CollisionShape2Dのコリジョン形状編集

「Area2D」ノードを選択した状態で、ノードドック>「シグナル」タブから「body_entered」シグナルを「Seed.gd」スクリプトに接続する。
Area2Dのシグナル接続

これで「Seed.gd」スクリプトに_on_Area2D_body_enteredメソッドが追加されたので、メソッドの中身を以下のように記述しよう。

func _on_Area2D_body_entered(body): # 追加
	if body.name != "Seed":
		print(body.name, " hits seed.")
		queue_free()

if body.name != "Seed":!=は「一致しない」という意味だ。このif構文がなければ、種が自身の物理ボディとの衝突により、インスタンスが生成された瞬間にすぐ解放される事になる。

これで、種が物理ボディ(主にプレイヤーキャラクター)に当たったら消える仕組みが復元できた。


Game.gd

最後に「Game.gd」スクリプトに戻って、必要な更新をしておこう。「# 追加」のコメントがある部分が更新箇所だ。

# ここまで省略

func add_level():
	level = load("res://Levels/Level" + str(current_level) + ".tscn").instance()
	level.connect("tree_exited", self, "change_level")
	add_child(level)
	# Player ノードを参照するプロパティを定義
	player = level.get_node("Player") # 追加
	# enemy_hit シグナルを _on_Player_enemy_hit メソッドにコードで接続
	player.connect("enemy_hit", self, "_on_Player_enemy_hit") # 追加


func change_level():
	#メソッド内省略


func _on_Player_enemy_hit(damage): # 追加
	health -= damage
	hud.update_health(health)

add_levelメソッドはゲーム開始時やレベルクリア時に次のレベルシーンのインスタンスを「Game」ルートノードに追加するためのメソッドだ。このメソッドが実行されるまでは「Game」シーンツリーに「Player」ノードは存在しない。そのため、このadd_levelメソッドで「Level_」シーンのインスタンスが「Game」シーンに追加されてから、「Player」ノードの自作のシグナルenemy_hitを、その後に新たに定義している_on_Player_enemy_hitメソッドに接続している。このようにconnectメソッドを使えばコードでシグナルを接続できる。

そして新たに定義した_on_Player_enemy_hitメソッドだが、これは通常ノードドックからのシグナル接続時に生成されるメソッドと同じだと思っていただければわかりやすいだろう。メソッド名もそれっぽくしたが、実際には何でも良い。enemy_hitシグナル発信時にこのメソッドが呼ばれる。引数のdamageにはシグナル発信メソッドemit_signalの引数damageの値が入る。処理として、まずhealthプロパティの値から引数damageが減算される。そのあと「HUD」ノードのupdate_healthメソッドが呼ばれ、引数には更新されたhealthプロパティの値が渡される。update_healthメソッドが実行されると「HUD」シーンの「HealthBar」が更新される(つまり緑色のバーが減る)。


それぞれの敵キャラクターのダメージを設定する

それぞれの敵キャラクターの.tscnファイルを開き、インスペクターで「Damege」プロパティの値を設定しよう。
インスペクターでdamageプロパティ設定

あなたのお好みの数値にしていただいて構わない。以下はサンプルとしてこのチュートリアル用に設定した値だ。

  • Mushroom - Damage: 8
  • Bunny - Damage: 32
  • Chameleon - Damage: 40
  • Plant - Damage: 24
    • Seed - Damage: 32

ここまでできたら、HUD のヘルスバーが正しく変動するか、一度プロジェクトを実行してみよう。
ヘルスバーのデバッグ

敵キャラクター自体への衝突、カメレオンの舌との衝突、およびプラントの種との衝突でダメージの処理がうまくいっているようなので、良しとしよう。次はスコアの処理だ。ここらで一度、小休憩を入れようじゃないか。



HUD に獲得したポイントの合計スコアを反映させる

以前 Part 6 のチュートリアルで「Item.gd」スクリプトにexportキーワード付きでpointというプロパティを用意し、インスペクター上でそれぞれのアイテム(フルーツ)のポイントを設定したことは覚えているだろうか。

サイト内記事リンク:
Godot で作るプラットフォーマー Part 6:アイテムを作ろう!

今まではただプレイヤーキャラクターがアイテムに当たったらそのポイントがふわりと画面上に出て消えるだけだったが、今回は以下の流れを作っていく。

  1. 「Player」ノードが「Item」ノードに当たる
  2. 「Player」ノードのitem_hitシグナルが発信される
  3. 発信されたitem_hitシグナルの引数にpointプロパティの値が渡される
  4. item_hitシグナルが接続されている「Game」ノードの_on_Player_item_hitメソッドが呼ばれる
  5. _on_Player_item_hitメソッドにより、「Game」ノードのscoreプロパティの値にpointの値が加算される
  6. 更新されたscoreプロパティの値が「HUD」ノードのupdate_scoreメソッドの引数として渡される
  7. update_scoreメソッドにより、「HUD」ノードの子である「ScoreText」ノードの「Text」プロパティの値に最新のscoreの値が反映される

スクリプトでプレイヤーキャラクターがアイテムに当たった時の制御をする

Item.gd

まずは「Item.gd」スクリプト側で、プレイヤーキャラクターがアイテムに当たった時に「Player」ノードのitem_hitシグナルを発信する仕組みを実装する。

「Item.gd」スクリプトを開いたら、以下のように編集しよう。

func _on_Item_body_entered(body):
	if body.name == "Player":
		print("Player hit Item")
		hit(body) # 引数に body を追加


func hit(player): # 新たに引数 player を定義
	print("Got ", point, " point.")
	player.emit_signal("item_hit", point) # 追加、Player の item_hit シグナルを発信
	anim_player.play("hit")
	yield(anim_player, "animation_finished")
	queue_free()

コード上の表記と順番が逆になるが、先にhitメソッドを更新した。引数playerを追加し、その引数に「Player」ノードが代入されるのを前提に、player.emit_signalメソッドでitem_hitシグナルを発信するように更新した。

次に、_on_Item_body_enteredメソッドの最後でhitメソッドが呼ばれるが、その引数には「Player」ノードとイコールであるbodyを代入している。

実は、hitメソッドはアイテムボックスの方のスクリプトでも呼ばれている。「ItemBox.gd」スクリプトを確認してみよう。編集したのは、アイテムボックス下部をプレイヤーキャラクターが小突いた時にシグナルで呼ばれる_on_Area2D_body_enteredというメソッドだ。

func _on_Area2D_body_entered(body):
	if body.name == "Player":
		print("ItemBox > Area2D entered by player")
		
		if timer_unused:
			timer.start()
			timer_unused = false
			
		sprite.play("hit")
		yield(sprite, "animation_finished")
		sprite.play("idle")
		
		if items.empty():
			print("ItemBox is empty.")
			sprite.visible = false
			var broken_box = broken_box_tscn.instance()
			parent.add_child(broken_box)
			broken_box.position = position
			queue_free()
			print(self.name, " removed.")
		else:
			print("ItemBox is not empty.")
			var item = items.pop_front().instance()
			add_child(item)
			item.position.y -= 12
			item.hit(body) # 引数に body を追加

プレイヤーがアイテムボックス下部の「Area2D」のコリジョン形状に当たって、その時アイテムボックスが空っぽではなかった場合(一番最後のelseブロック)、最後に「Item」ノードのhitメソッドが呼ばれる。さっきちょうど更新したメソッドだ。ここでもbodyイコール「Player」ノードなので、hitメソッドの引数playerbodyを代入して実行している。これで、アイテムボックスからアイテムが飛び出すたびに「Player」ノードのitem_hitシグナルが発信するようになった。

Game.gd

次に「Game.gd」スクリプトを開いて編集しよう。

add_levelメソッドの編集から始める。

func add_level():
	level = load("res://Levels/Level" + str(current_level) + ".tscn").instance()
	level.connect("tree_exited", self, "change_level")
	add_child(level)
	player = level.get_node("Player") # さっき追加した
	player.connect("enemy_hit", self, "_on_Player_enemy_hit") # さっき追加した
	player.connect("item_hit", self, "_on_Player_item_hit") # 追加

「Player」ノードのitem_hitシグナルを_on_Player_item_hitというメソッドに接続する。このメソッドはこの後定義する。

func _on_Player_item_hit(point): # 追加
	score += point
	hud.update_score(score)

_on_Player_item_hitを定義した。このメソッドの引数にはitem_hitシグナルの引数pointの値が入る。scoreからそのpointが減算されて、更新されたscoreを引数として「HUD」ノードのupdate_scoreメソッドが呼ばれる。これで HUD のスコア表示に最新のscoreプロパティの値が反映する。

ではプロジェクトを実行して、アイテムに当たった時の HUD の変化を確認してみよう。
スコアのデバッグ

(マッシュルームを踏んだ時にダメージをくらっているが)直接アイテムに当たった時も、アイテムボックスでアイテムが飛び出した時も、両方ともスコアに獲得したポイントが加算されたので、想定通りの挙動と言って良いだろう。



HUD に現在のレベル数を反映させる

HUD のレベル数の変化をスクリプトで制御していこう。と言っても、このセクションが一番簡単だから安心してほしい。「Game.gd」スクリプトを開いて、change_levelメソッドを以下のように編集しよう。「# 追加」とコメントしている行だ。

func change_level():
	print("change_level() called.")
	if current_level < final_level:
		print("change to next level.")
		level.queue_free()
		current_level += 1
		hud.update_level(current_level) # 追加
		add_level()
	else:
		print("Game Clear! Congrats!")
		get_tree().quit()

「HUD」ノードのupdate_levelメソッドは事前に定義済みなので、これでレベル1をクリアしてレベル2に遷移するときに HUD の Level の表記が更新されるはずだ。

では、実際にプロジェクトを実行して確認してみよう。
レベルのデバッグ

きちんとレベルが 1 から 2 に切り替わったのが確認できた。問題ないだろう。以上で今回のチュートリアルの HUD 実装作業は完了だ。



Part 8 で編集したスクリプトのコード

最後に今回の Part 8 で編集したスクリプトのコードを共有しておくので、必要に応じて確認してほしい。

HUD.gd の全コード
extends Control


var score = 0
onready var health_bar = $HUDHBox/HealthHBox/HealthBar
onready var score_text = $HUDHBox/ScoreText
onready var level_texture = $HUDHBox/LevelHBox/LevelTexture


func _ready():
	health_bar.value = 100


func update_health(health):
	health_bar.value = health


func update_score(score):
	score_text.text = "score " + str(score)


func update_level(level):
	var str_level
	if level < 10:
		str_level = "0" + str(level)
	elif level <= 51:
		str_level = str(level)
	var file = load("res://Assets/Menu/Levels/" + str_level + ".png")
	level_texture.texture = file

Game.gd の全コード
extends Node

var health: float = 100.0 # 追加
var score: int = 0 # 追加
var level: Node2D
var player: KinematicBody2D

export var current_level = 1
export var final_level = 2

onready var hud = $HUDLayer/HUD # 追加


func _ready():
	add_level()
	hud.update_health(health) # 追加
	hud.update_score(score) # 追加
	hud.update_level(current_level) # 追加


func add_level():
	level = load("res://Levels/Level" + str(current_level) + ".tscn").instance()
	level.connect("tree_exited", self, "change_level")
	add_child(level)
	player = level.get_node("Player") # 追加
	player.connect("enemy_hit", self, "_on_Player_enemy_hit") # 追加
	player.connect("item_hit", self, "_on_Player_item_hit") # 追加


func change_level():
	print("change_level() called.")
	if current_level < final_level:
		print("change to next level.")
		level.queue_free()
		current_level += 1
		hud.update_level(current_level) # 追加
		add_level()
	else:
		print("Game Clear! Congrats!")
		get_tree().quit()


func _on_Player_enemy_hit(damage): # 追加
	print("Health updated: ", health)
	health -= damage
	hud.update_health(health)


func _on_Player_item_hit(point): # 追加
	score += point
	hud.update_score(score)

Player.gd の全コード
extends KinematicBody2D # Created @ Part 1

signal enemy_hit(damage) # 追加
signal item_hit(point) # 追加

export var acceleration = 256
export var max_speed = 64
export var max_dash_speed = 200
export var friction = 0.1
export var gravity = 512
export var jump_force = 224
export var air_resistance = 0.02
var velocity = Vector2()
onready var sprite = $AnimatedSprite
onready var anim_player = $AnimationPlayer # Added @ Part 7


func _ready(): # Added @ Part 7
	sprite.position = Vector2(0, 0)
	sprite.scale = Vector2(1, 1)
	sprite.modulate = Color(1, 1, 1, 1)


func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
		sprite.flip_h = x_input < 0
	
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")

		if Input.is_action_just_pressed("jump"):
			sprite.play("jump")
			velocity.y = -jump_force

	else:
		if x_input == 0:
			velocity.x = lerp(velocity.x, 0, air_resistance)

		if Input.is_action_just_released("jump") and velocity.y < -jump_force / 2:
			velocity.y = -jump_force / 2

	velocity = move_and_slide(velocity, Vector2.UP)

	# Added @ Part 3
	if position.x < 16:
		position.x = 16


func _on_HitBox_body_entered(body): # 追加
	if body.is_in_group("Enemies"):
		print("Enemy hit player. Damage is ", body.damage)
		emit_signal("enemy_hit", body.damage)
		sprite.play("hit")

Enemy.gd の全コード
extends KinematicBody2D # Added @ Part 4


export var gravity: int
export var speed: int
export var damage: float # 追加
var velocity = Vector2()
onready var sprite = $AnimatedSprite


func _ready():
	set_physics_process(false)


func _on_HitBox_body_entered(body):
	if body.is_in_group("Players"):
		print("Player entered in ", self.name)
		sprite.play("hit")
		yield(sprite, "animation_finished")
		queue_free()
		print(self.name, " died")


func _on_VisibilityEnabler2D_screen_entered():
	set_physics_process(true)


func _on_VisibilityEnabler2D_screen_exited():
	set_physics_process(false)

Seed.gd の全コード
extends StaticBody2D # Added @ Part 5 / Modified @ Part 8


export var speed = 150
export var damage = 32 # 追加


func _physics_process(delta):
	position.x -= speed * delta


#func _on_Seed_body_entered(body): # 削除
#	if body.name == "Player":
#		print("Seed hits player.")
#	queue_free()


func _on_VisibilityNotifier2D_viewport_exited(viewport):
	print("viewport_exited method called")
	queue_free()


func _on_Area2D_body_entered(body): # 追加
	if body.name != "Seed":
		print(body.name, " hits seed.")
		queue_free()

Chameleon.gd の全コード
extends "res://Enemies/Enemy.gd" # Added @ Part 5

var tongue_hit_enabled = true # 追加
onready var raycast = $RayCast2D


func _ready():
	sprite.play("idle")
	

func _physics_process(delta):
	if raycast.is_colliding():
		if raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) > 80: 
				run()
			else:
				attack()
				velocity.x = 0
	else:
		sprite.play("idle")
		velocity.x = 0
	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2.UP)


func run():
	sprite.play("run")
	if is_on_wall():
		speed *= -1
		sprite.flip_h = !sprite.flip_h
		sprite.position.x *= -1
		raycast.cast_to.x *= -1
	velocity.x = -speed


func attack():
	sprite.play("attack")
	if sprite.frame == 6 or sprite.frame == 7:
		if raycast.is_colliding() and raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) < 50:
				print("Chameleon's tongue hits player.")
				if tongue_hit_enabled: # 追加
					raycast.get_collider().emit_signal("enemy_hit", damage)
					tongue_hit_enabled = false
					yield(get_tree().create_timer(0.83), "timeout")
					tongue_hit_enabled = true

Item.gd の全コード
extends Area2D # Added @ Part 6


export var point = 100
onready var sprite = $AnimatedSprite
onready var label = $Label
onready var anim_player = $AnimationPlayer


func _ready():
	sprite.modulate = Color(1, 1, 1, 1)
	sprite.position = Vector2.ZERO
	sprite.scale = Vector2.ONE
	label.modulate = Color(1, 1, 1, 0)
	label.rect_position = Vector2(-32, -20)
	label.text = str(point)


func _on_Item_body_entered(body):
	if body.name == "Player":
		print("Player hit Item")
		hit(body) # 変更

func hit(body): # 変更
	print("Got ", point, " point.")
	body.emit_signal("item_hit", point) # 追加
	anim_player.play("hit")
	yield(anim_player, "animation_finished")
	queue_free()

ItemBox.gd の全コード
extends StaticBody2D # Added @ Part 6

var timer_unused = true
onready var sprite = $AnimatedSprite
onready var timer = $Timer
onready var parent = get_parent()
onready var broken_box_tscn = preload("res://Items/BrokenBox.tscn")
onready var items = [
	preload("res://Items/Apple.tscn"),
	preload("res://Items/Bananas.tscn"),
	preload("res://Items/Cherries.tscn"),
	preload("res://Items/Kiwi.tscn"),
	preload("res://Items/Melon.tscn"),
	preload("res://Items/Orange.tscn"),
	preload("res://Items/Pineapple.tscn"),
	preload("res://Items/Strawberry.tscn"),
]


func _ready():
	sprite.play("idle")


func _on_Timer_timeout():
	print("ItemBox > Timer timeout")
	if not items.empty():
		items.clear()
		print("items size: ", items.size())


func _on_Area2D_body_entered(body):
	if body.name == "Player":
		print("ItemBox > Area2D entered by player")
		
		if timer_unused:
			timer.start()
			timer_unused = false
			
		sprite.play("hit")
		yield(sprite, "animation_finished")
		sprite.play("idle")
		
		if items.empty():
			print("ItemBox is empty.")
			sprite.visible = false
			var broken_box = broken_box_tscn.instance()
			parent.add_child(broken_box)
			broken_box.position = position
			queue_free()
			print(self.name, " removed.")
		else:
			print("ItemBox is not empty.")
			var item = items.pop_front().instance()
			add_child(item)
			item.position.y -= 12
			item.hit(body) # 変更


おわりに

以上で Part 8 は完了だ。今回は HUD を実装した。HUD の見た目を作るまではそれなりにサクサクと進められたと思う。しかし、複数のスクリプトを跨いだデータの受け渡しは、なかなか複雑でややこしいと感じる人は多いのではないだろうか。今回のチュートリアルでもお伝えしたように、先に図や絵を描いて頭の中を整理すると、その後のコーディングもよりスムーズに進められることが多い。今後もしあなた自身のオリジナルプロジェクトを作る場面で役立ちそうであれば、ぜひお試しいただければと思う。急がば回れ、である。

さて、次回のチュートリアルでは、ゲームオーバーの仕組みを実装していく。プレイヤーキャラクターのヘルスが 0 になるか、画面下に落下したらゲームオーバー画面が表示されるという仕組みを実装していく。

では次回もお楽しみに。