第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 現在)。
見つかったらそれをクリックしてダウンロードする。
この時、おそらく「icon.png」が競合していると警告される。競合するファイルはインストールする前にチェックを外しておこう。
インストールが完了したら OK をクリックする。
ファイルシステムを覗いてみると、ダウンロードしたフォントアセットが見つかるはずだ。
HUD シーンを作る
新規シーンを作成して必要なノードを追加する
まずは新規で HUD のシーンを作ろう。
- 「シーン」メニュー>「新規シーン」を選択する
- 「ルートノードの生成」で「ユーザーインターフェース」を選択し、「Control」クラスのルートノードにし、名前を「HUD」に変更する
- 「HUD」に「HBoxContainer」を追加し、名前を「HUDHBox」にする
- 「HUDHBox」に「HBoxContainer」ノードを追加し、名前を「HealthHBox」にする
- 「HealthHBox」に「Label」ノードを追加し、名前を「HealthText」にする
- 「HealthHBox」に「TextureProgress」ノードを追加し、名前を「HealthBar」にする
- 「HUDHBox」に「Label」ノードを追加し、名前を「ScoreText」にする
- 「HUDHBox」に「HBoxContainer」ノードを追加し、名前を「LevelHBox」にする
- 「LevelHBox」に「Label」ノードを追加し、名前を「LevelText」にする
- 「LevelHBox」に「TextureRect」ノードを追加し、名前を「LevelTexture」にする
- 「UI」フォルダを作成し、ファイルパスを「res://UI/HUD.tscn」としてシーンを保存する
シーンツリーに必要なノードは揃ったので、次はそれぞれのノードのプロパティを編集する。
新しく登場するノードがあるので、それぞれ簡単に用途を説明しておこう。公式オンラインドキュメントのそれぞれのクラスの説明ページも必要に応じて確認いただきたい。
「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全面」を選択して、画面一杯に広げる
HUDHBox ノード
- ツールバーで「レイアウト」>「上伸長」を選択して、画面上部で横一杯に広げる
- 「HUDHBox」を選択してインスペクターから「Alignment」プロパティを「Center」にする
- 「Margin」プロパティを(Left: 8, Top: 8, Right: -8, Bottom: 0)にして画面端に少し余白を作る
- 「Theme Overrides」>「Constants」>「Separation」プロパティを 16 にする
HealthHBox ノード
このノードのプロパティは編集不要だ。
HealthText ノード
- 「HealthText」ノードを選択して、インスペクターから「Text」プロパティに「health」と入力する
- 「Uppercase」プロパティをオンにする
- 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」(事前に準備したフォント)をドラッグ&ドロップして適用する。
- 追加したフォントをクリックし、「Settings」>「Size」プロパティを 16 にする
- さらに「Extra Spacing」>「Top」プロパティを -8 にする
HealthBar ノード
- 「HealthBar」ノードを選択したら、インスペクターで「Nine Patch Stretch」をオンにして、テクスチャのサイズを柔軟に変更できるようにする
- 「Textures」>「Under」にファイルシステムから「res://Assets/Background/Pink.png」をドラッグ&ドロップして適用する(ヘルスバー用のアセットがないのであり物でやりくりするのだ)
- 今回「Textures」>「Over」プロパティは設定せずそのまま
- 同様に「Textures」>「Progress」プロパティにファイルシステムから「res://Assets/Background/Green.png」をドラッグ&ドロップして適用する
- テクスチャの柄が目立たないように「Tint」>「Under」プロパティの値を # 000000 にして大幅に色を変える
- 同様に「Tint」>「Progress」プロパティの値を # 26ab3c にする
- 「Range」>「Value」プロパティの値を 100 にする(この値に連動して「Progress」のテクスチャサイズが変化する)
- 「Rect」>「Min Size」プロパティの x の値を 100 にする
ScoreText ノード
「HealthText」ノードと同様の手順だ。
- 「ScoreText」ノードを選択したら、インスペクターで「Text」プロパティに「score 0」と入力する
- 「Uppercase」プロパティをオンにする
- 「Size Flags」>「Horizontal」で「Fill」と「Expand」にチェックを入れる
- 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」をドラッグ&ドロップして適用する。
LevelHBox ノード
このノードのプロパティは編集不要だ。
LevelText ノード
「HealthText」ノードや「ScoreText」と同様の手順だ。
- 「LevelText」ノードを選択したら、インスペクターで「Text」プロパティに「level」と入力する
- 「Uppercase」プロパティをオンにする
- 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムから「res://fonts/poco/Poco.tres」をドラッグ&ドロップして適用する。
LevelTexture ノード
「LevelTexture」ノードを選択したら、インスペクターで「Texture」プロパティに、ファイルシステムから「res://Assets/Menu/Levels/01.png」をドラッグ&ドロップして適用する
ちなみに画像にブラーがかかっていたら、インポートドックで Pixel 2D のプリセットを適用して再インポートしておこう。
HUD シーンをインスタンス化して Game シーンに追加する
それでは、ここまでに作成した「HUD」シーンを「Game」シーンに追加していこう。
- まずは「Game.tscn」を開く
- 「Game」ルートノードに「CanvasLayer」ノードを追加し、名前を「HUDLayer」に変更する
- 「HUDLayer」ノードに「HUD.tscn」シーンをインスタンス化して追加する
シーンツリーが以下のようになればOKだ。
ちなみに「CanvasLayer」クラスのノードを追加した理由は、このゲームのカメラを担当する「Camera2D」ノードとはレイヤーを別ける必要があったからだ。そうしないと、プレイヤキャラクターを操作するやいなや、HUDの位置がズレていってしまう。「CanvasLayer」の子として「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
メソッドの中で実行している。これにより、ゲーム開始時にhealth
、score
、level
の3つのプロパティの初期値が HUD に反映される。
HUD にプレイヤーキャラクターが受けたダメージを反映させる
ここからは、プレイヤーのヘルス管理を実装していく。
Player シーンに HitBox を追加する
長らく放置していた、プレイヤーキャラクターのダメージを受ける仕組みを作る時が来た。敵に当たった時にシグナルを発信させてダメージ処理を行いたいので、敵キャラクターと同様に、下記手順でプレイヤーキャラクターにも「HitBox」を作成するところから始めていこう。
- 「Player.tscn」を開く
- 「Player」ルートノードに「Area2D」ノードを追加し、名前を「HitBox」に変更する
- 「HitBox」ノードに「CollisionShape2D」ノードを追加する
- 追加した「CollisionShape2D」の「Shape」プロパティに「新規RectangleShape2D」を設定する。
- 追加した「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」スクリプトに接続しよう。
すると、「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」ルートノードを右クリックし、「型を変更」を選択する。そして「StaticBody2D」を選択しよう。これで種も物理ボディになった。
しかし、スクリプトの方はまだ「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」ノードを追加する。
「CollisionShape2D」のコリジョン形状を調整する。「Shape」プロパティに「新規 CircleShape2D」を割り当てる。2Dワークスペースで、「Seed」ルートノード直下の「CollisionShape2D」の形状より 1 px 大きめに設定した。「Radius」プロパティは 5 だ。
「Area2D」ノードを選択した状態で、ノードドック>「シグナル」タブから「body_entered」シグナルを「Seed.gd」スクリプトに接続する。
これで「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」プロパティの値を設定しよう。
あなたのお好みの数値にしていただいて構わない。以下はサンプルとしてこのチュートリアル用に設定した値だ。
- 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:アイテムを作ろう!
今まではただプレイヤーキャラクターがアイテムに当たったらそのポイントがふわりと画面上に出て消えるだけだったが、今回は以下の流れを作っていく。
- 「Player」ノードが「Item」ノードに当たる
- 「Player」ノードの
item_hit
シグナルが発信される - 発信された
item_hit
シグナルの引数にpoint
プロパティの値が渡される item_hit
シグナルが接続されている「Game」ノードの_on_Player_item_hit
メソッドが呼ばれる_on_Player_item_hit
メソッドにより、「Game」ノードのscore
プロパティの値にpoint
の値が加算される- 更新された
score
プロパティの値が「HUD」ノードのupdate_score
メソッドの引数として渡される 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
メソッドの引数player
にbody
を代入して実行している。これで、アイテムボックスからアイテムが飛び出すたびに「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 になるか、画面下に落下したらゲームオーバー画面が表示されるという仕組みを実装していく。
では次回もお楽しみに。