第10回目の今回は、ゲーム開始時に表示されるスタート画面を作る。タイトル画面、スタートメニューとも言う。

それだけだと、前回と比べて簡単すぎるので、ゲーム開始時にプレイヤーキャラクターを選択できる仕組みも実装する。画面遷移のイメージは以下の通りだ。

  1. まずゲームを始めるとスタート画面が表示される。
  2. スタートボタンを押すとプレイヤーキャラクター選択画面に遷移する。
  3. プレイヤーキャラクターを選択するとゲームプレイ開始。

ということで、今回の作業は以下の通りだ。

  • スタート画面を作る
  • プレイヤーキャラクターの種類(スプライトのテクスチャのみ)を増やす
  • プレイヤーキャラクター選択画面を作る
  • スタート画面とプレイヤーキャラクター選択画面をゲームに紐付ける

では順番にやっていこう。

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



スタート画面を作る

シーンを作る

まずはスタート画面を作っていく。画面のレイアウトはゲームオーバー画面と似た感じにしてしまおう。そうすればゲームオーバー画面のシーンを流用できる。

ということで、新規シーンは作らずに、ファイルシステムドック内でゲームオーバー画面のシーンを複製するところから作業を始める。では下記の手順でスタート画面のシーンを作成しよう。

  1. ファイルシステムで「res://UI/GameOver/GameOver.tscn」を右クリック>「複製」を選択する。
    GameOver.tscnを複製
  2. シーンの名前を「GameStart.tscn」とする。
  3. ファイルシステムで「res://UI/」で右クリック>「新規フォルダー」を選択し、フォルダーの名前を「GameStart」とする。
  4. 「res://UI/GameStart」に「GameStart.tscn」をドラッグして移動する
    GameStartフォルダ作成

シーンツリーを編集する

「GameOver.tscn」から複製したばかりの「GameStart.tscn」を開いてみよう。
GameStartシーンツリー複製直後

見ての通り、「GameOver」シーンそのままの状態だ。これを以下の手順でスタート画面仕様に修正していく。

  1. 「GameOver」ルートノードの名前を「GameStart」に変更する。
  2. 「GameStart」ノードを右クリック>「スクリプトをデタッチ」する。
  3. 「GameOverLabel」ノードの名前を「TitleLabel」に変更する。
  4. 「RestartVBox」ノードの名前を「StartVBox」に変更する。
  5. 「RestartButton」ノードの名前を「StartButton」に変更する。
  6. 「RestartLabel」ノードの名前を「StartLabel」に変更する。
  7. 「StartVBox」ブランチを「ButtonsHBox」ノードから出して「VBoxContainer」の子にする(1階層上げる)
  8. 「ButtonsHBox」ブランチを削除する。
  9. 「ColorRect」ノードを削除する。
  10. 「ConfirmationDialog」ノードを削除する。
  11. 新たに「GameStart」ルートノードに「AnimationPlayer」ノードを追加する。

以上の作業でシーンツリーは以下のスクリーンショットのようになったはずだ。
GameStartシーンツリー修正後

最後に「StartButton」ノードの「button_up()」シグナルを切断しておこう。

続いて、各ノードのプロパティを更新していく。


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

各ノードのプロパティの値も、シーンの複製元である「GameOver.tscn」の状態のままなのでスタート画面仕様に修正していく。シーンツリーの上から順番にやっていこう。


GameStart

「Pause Mode」プロパティは、「GameOver」シーンの名残で「Process」のままになっているので、「Inherit」にしておく。その他のプロパティも編集は不要だ。


TextureRect

「Texture」プロパティに割り当てるテクスチャ画像を差し替えて、ゲームオーバー画面と差別化する。ファイルシステムドックから「res://Assets/Background/Pink.png」をドラッグ&ドロップして適用する。
Textureプロパティ


VBoxContainer

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


TitleLabel

スタート画面は、ゲーム開始時に最初に表示される画面なので、ゲームのタイトルを大きめに表示させたい。また、文字の見た目も色を変えたりアウトラインをつけるなどして、ゲームオーバー画面と差別化する。

  1. 「Text」プロパティに以下のゲームタイトルの文字列(改行あり)を入力する(あなたの好きなタイトルにしていただいて問題ない)。
    platformer
    tutorial
  2. 「Theme Overrides」>「Fonts」>「Font」で「新規 DynamicFont」を選択する。
  3. 「Font」>「Settings」>「Size」プロパティを 48 にする。
  4. 「Font」>「Settings」>「Outline Size」プロパティを 5 にする。
  5. 「Font」>「Settings」>「Outline Color」プロパティで色を # 302e3e (濃いグレー)にする。
  6. 「Font」>「Font」>「Font Data」に「res://fonts/connection_ii/ConnectionII.otf」を適用する。
    TitleLabelプロパティ
  7. 「Visibility」>「Modulate」プロパティで色を # 7ec8ff(水色)にする。
    TitleLabelプロパティ

StartVBox

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


StartButton

ボタンのアイコンをゲームプレイを開始するイメージに近いテクスチャ画像に差し替える。

  1. 「Textures」>「Normal」プロパティにファイルシステムドックから「res://Assets/Menu/Buttons/Play.png」をドラッグ&ドロップして画像を差し替える。
    StartButtonプロパティ

StartLabel

ボタンアイコンの説明書きがゲームオーバー画面仕様の「RESTART」になっているので「START」に更新する。

  1. 「Text」プロパティに「press space or enter key」と入力する。

AnimationPlayer

このノードは「Start」ボタンを押した時に、少し凹んだようなアニメーションを作るために用いる。インスペクターのプロパティはそのままで良い。

新規アニメーションを以下の内容で作成しよう。

  • アニメーション名: press_start
  • アニメーションの長さ(秒): 0.1
  • アニメーションループ: 無効
  • トラック:
    • 「StartButton」ノード >「modulate」プロパティ
      • Time: 0秒 / Value: # ffffff / Easing: 1.00
      • Time: 0.05秒 / Value: # c8c8c8 / Easing: 1.00
      • Time: 0.1秒 / Value: # ffffff / Easing: 1.00
    • 「StartButton」ノード >「rect_scale」プロパティ
      • Time: 0秒 / Value: (1, 1) / Easing: 1.00
      • Time: 0.05秒 / Value: (0.9, 0.9) / Easing: 1.00
      • Time: 0秒 / Value: (1, 1) / Easing: 1.00

以上で、スタート画面のプロパティ編集作業は完了だ。一からシーンを作るよりは省エネで作業を終えられたのではないだろうか。ちなみに、シーンの見た目は以下のようになったはずだ。
GameStartシーンを実行


スクリプトをアタッチして編集する

続いて、スタート画面上のボタンをプレイヤーがクリックした時の動作をスクリプトで制御していく。

「GameStart」ルートノードに新しいスクリプトをアタッチする。この時、スクリプトのファイルパスを「res://UI/GameStart/GameStart.gd」として作成する。

スクリプトエディタで「GameStart.gd」スクリプトが開いたら、以下のように編集する。

extends Control

# StartButton ノードの参照
onready var start_button = $VBoxContainer/StartVBox/StartButton
# AnimationPlayer ノードの参照
onready var anim_player = $AnimationPlayer


func _ready():
	# ゲーム開始時にアニメーションで変更されるプロパティを初期化
	start_button.modulate = Color(1, 1, 1, 1)
	start_button.rect_scale = Vector2(1, 1)

次にシグナルを接続しよう。「StartButton」ノードの「button_up()」シグナルを「GameStart.gd」スクリプトに接続する。
button_up()シグナル

スクリプトに_on_StartButton_button_upメソッドが生成されたら、以下のように編集する。


# Start ボタンをクリックした時のシグナルで呼ばれるメソッド
func _on_StartButton_button_up():
	# デバッグ用
	print("_on_StartButton_button_up called.")
	# AnimationPlayer の press_start アニメーションを再生
	anim_player.play("press_start")
	# アニメーションが終わるまで待機
	yield(anim_player, "animation_finished")
	# このノードを解放(削除)する
	queue_free()
	# デバッグ用
	print("GameStart scene is now freed.")

Start ボタンを押したら、次のプレイヤーキャラクター選択画面に遷移させる予定なので、この時点で「GameStart」シーンは用済みなのでqueue_freeメソッドで解放している。


最後にキー入力での制御を実装しておく。インプットマップにデフォルトで登録されているアクション「ui_accept」に該当するスペースキー、またはエンターキーを押した時に、さっきシグナル接続で生成された_on_StartButton_button_upを実行するようにした。これでマウスでもキーボードでも同じ結果が得られるようになった。


func _input(event):
	# スペースキーかエンターキーが押された場合
	if event.is_action_pressed("ui_accept"):
		# スタートボタンが押された時のメソッドを呼ぶ
		_on_StartButton_button_up()

「GameStart」シーンの作成はこれで完成なので、最後にシーンを実行して挙動を確認しておこう。
GameStartシーンを実行

スタートボタンを押すと、ルートノードが解放されて何もノードが存在しない状態になるので、グレーの画面が表示されるのは正常な動作だ。なお、GIF画像ではアニメーションが若干不自然だが、実際にはもう少し短い時間で自然にアニメーションするはずだ。



プレイヤーキャラクターの種類を増やす

ファイルシステムドックをご覧いただくと「res://Assets/Main Characters/」フォルダの中に4種類のプレイヤーキャラクターのアセットが見つかるだろう。ここまでのチュートリアルでは、4種類のうち「Pink Man」のスプライトシートのみを使ってきたが、ここで4種類全てのアセットを使用する時がきた。

とはいえ、4種類のプレイヤーキャラクターのシーンを作る必要はなく、「Player」シーン>「AnimatedSprite」ノード>「Frames」プロパティに適用する「SpriteFrames」クラスのリソースファイルを4種類作成するだけで良い。つまり、キャラクター選択時に、「AnimatedSprite」ノードのテクスチャだけ差し替える、という作戦だ。

(おさらいも兼ねて)事前確認として「Pink Man」以外のスプライトシートのアセットファイルにもしブラーがかかっていたら(ぼやけた感じがしたら)、ファイルシステムドックでファイルを選択して、インポートドックから「プリセット」>「2D Pixel」を選択して「再インポート」をクリックしておこう。これでドット絵特有のエッジが効いた表示に更新される。

では順番にやっていこう。


SpriteFrames リソースを作成する

PinkMan

ほぼ作業不要の「PinkMan」から「SpriteFrames」クラスのリソースファイルを作成する。

  1. 「Player.tscn」を開く。
  2. 「AnimatedSprite」ノードの「Frames」プロパティの値になっている「SpriteFrames」を右クリック>「保存」を選択する。
    リソースを保存
  3. ファイルパスが「res://Player/PinkMan.tres」になるようにファイル名を「PinkMan.tres」として保存する。
    リソースを保存

PinkMan の場合はすでにアニメーションフレームが作成済みなので、リソースファイル作成はこれで完了だ。


MaskDude

次は「MaskDude」のリソースファイルを作成する。

  1. ファイルシステムドックで、先に作成したPinkManのリソース「res://Player/PinkMan.tres」を右クリック>「複製」を選択する。
  2. ファイル名を「MaskDude.tres」とする。
    tresファイルを複製
  3. ファイルシステムドックで作成した「MaskDude.tres」をダブルクリックしてスプライトフレームパネルを開く。
  4. 登録しているアニメーションを上から一つ選択する。
    アニメーション選択
  5. 既存のアニメーションフレームを全て削除する。
    アニメーションフレームの削除
  6. スプライトシートアイコンをクリックする。
    スプライトシートアイコン
  7. 適切なスプライトシートのファイルを選択し、アニメーションフレームに反映すればOK。
    スプライトシートの選択と割り当て
  8. 次のアニメーションを選択して同様の作業を行う。全てのアニメーションのアニメーションフレームが更新できたら、リソースファイルの作成は完了とする。

NinjaFrog

作業は「MaskDude」シーンと同様だ。リソースのファイル名はもちろん「NinjaFrog.tres」にして保存する。


VirtualGuy

こちらも作業は「MaskDude」シーンと同様だ。リソースのファイル名はもちろん「VirtualGuy.tres」にして保存する。

「VirtualGuy.tres」の編集が終われば、プレイヤーキャラクター4種類のリソースファイルの完成だ。ここらで一度休憩を入れよう。



プレイヤーキャラクター選択画面を作る

さて、ここからはプレイヤーキャラクターを選択する画面のシーンを作っていく。画面の仕様は以下のようにする。

  • 画面上部にプレイヤーキャラクターの名前を表示する。
  • 画面中央にプレイヤーキャラクターのビジュアル(テクスチャ)をアニメーション付きで表示する。
  • キーボードの左右矢印キーか画面に表示されている矢印ボタンをクリックでキャラクターを切り替える。
  • キーボードのスペースキーまたはエンターキーでキャラクターを選択したらゲームプレイ開始。

では例によって、シーンを作成するところから始めよう。


シーンを作る

以下の手順でプレイヤーキャラクター選択用のシーンを作っていく。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. 「ルートノードの生成」で「ユーザーインターフェース」を選択して、「Control」クラスを選択する。
  3. 「Control」ルートノードが生成されたら、名前を「CharacterSelect」に変更する。
  4. 「res://UI」フォルダーに「CharacterSelect」フォルダーを新規作成し、ファイルパスを「res://UI/CharacterSelect/CharacterSelect.tscn」としてシーンを保存する。

必要なノードを追加する

続いて、作成した「CharacterSelect」シーンに必要なノードを追加していこう。

  1. 「CharacterSelect」ルートノードに「TextureRect」ノードを追加する。
  2. 「CharacterSelect」ルートノードに「VBoxContainer」ノードを追加する。
  3. 「VBoxContainer」ノードに「Label」ノードを追加し、名前を「CharacterName」に変更する。
  4. 「VBoxContainer」ノードに「HBoxContainer」ノードを追加する。
  5. 「HBoxContainer」ノードに、「TextureButton」ノードを 2 つ追加し、それぞれの名前を「LeftButton」、「RightButton」に変更する。
  6. 「VBoxContainer」ノードに「Label」ノードを追加し、名前を「Caption」に変更する。
  7. 「CharacterSelect」ノードに「AnimatedSprite」ノードを追加する。
  8. 「CharacterSelect」ノードに「AnimationPlayer」ノードを追加する。

ここまででシーンツリーは以下のようになったはずだ。
スプライトシートの選択と割り当て


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

次に、インスペクターで各ノードのプロパティを編集して、プレイヤーキャラクター選択画面をデザインしていこう。


CharacterSelect

このルートノードは子ノードの入れ物として利用する。

  1. 2Dワークスペースのツールバー>「レイアウト」から「Rect全面」を選択して、ノードのサイズを前画面サイズに揃える。

TextureRect

このノードは背景用に利用する。

  1. 2Dワークスペースのツールバー>「レイアウト」から「Rect全面」を選択して、画面全体に広げる。
  2. 「Texture」プロパティに、ファイルシステムから「res://Assets/Background/Yellow.png」をドラッグ&ドロップして適用する。
  3. 「Stretch Mode」プロパティを「Tile」にする。

TextureRectプロパティ編集後


VBoxContainer

このノードはプレイヤーキャラクター選択画面に表示されるキャラクター名やキャラクターのスプライト、選択ボタン、操作説明を縦に整列させるためのコンテナとして利用するだけなので、プロパティの編集は少ない。

  1. 2Dワークスペースのツールバー>「レイアウト」から「Rect全面」を選択して、ノードのサイズを前画面サイズに揃える。
  2. インスペクターで「Alignment」を「Center」にする。
  3. 「Theme Overrides」>「Constants」>「Separation」ノードを 48 にする。

CharacterName

このノードは、現在選択しているキャラクターの名前を表示するのに利用する。表示位置は画面の上部中央になる予定だが、他のノードの設定がまだなので現時点では中央にされる。後から徐々に意図した形になるはずなので、位置はそのままにしておくこと。

  1. インスペクターで「Text」プロパティは仮に「Mask Dude」と入力する。
  2. 「Align」プロパティを「Center」にする。
  3. 「Theme Overrides」>「Fonts」>「Font」で「新規 DynamicFont」を選択する。
  4. 「Font」>「Settings」>「Size」プロパティを 32 にする。
  5. 「Font」>「Settings」>「Outline Size」プロパティを 5 にする。
  6. 「Font」>「Settings」>「Outline Color」プロパティで色を # 302e3e (濃いグレー)にする。
  7. 「Font」>「Font」>「Font Data」に「res://fonts/connection_ii/ConnectionII.otf」を適用する。

CharacterNameプロパティ編集後


HBoxContainer

このノードは水平方向に子ノードを横並びに配置するコンテナだ。左右の矢印ボタンアイコンを子ノードに持つので、それを横並びに配置する。またその左右の矢印ボタンの間に、プレイヤーキャラクターのスプライトを表示させたいので、ボタンとボタンの間をたっぷり目に空けておく必要がある。

  1. インスペクターで「Alignment」プロパティを「Center」にする。
  2. 「Theme Overrides」>「Constants」>「Separation」ノードを 156 にする。

LeftButton

このノードは「TextureRect」クラスなので、ボタンにテクスチャ画像を割り当てることができる。名前の通り、左向き矢印ボタンを作るのに利用する。

  1. インスペクターで「Expand」プロパティをオンにする。
  2. 「Stretch Mode」プロパティを「Keep Aspect Centered」にする。
  3. 「Textures」>「Normal」プロパティに、ファイルシステムドックから「res://Assets/Menu/Buttons/Previous.png」をドラッグ&ドロップして適用する。
  4. 「Control」クラスの「Rect」>「Min Size」プロパティを (42, 40) にする。

LeftButtonプロパティ編集後


RightButton

このノードも「LeftButton」同様に「TextureRect」クラスで、右向き矢印ボタンを作るのに利用する。

  1. インスペクターで「Expand」プロパティをオンにする。
  2. 「Stretch Mode」プロパティを「Keep Aspect Centered」にする。
  3. 「Textures」>「Normal」プロパティに、ファイルシステムドックから「res://Assets/Menu/Buttons/Next.png」をドラッグ&ドロップして適用する。
  4. 「Control」クラスの「Rect」>「Min Size」プロパティを (42, 40) にする。

RightButtonプロパティ編集後


Caption

このノードは、単なる「Label」クラスのノードである。プレイヤーキャラクター選択画面での操作説明を画面下部に表示させるために利用する。

  1. インスペクターで「Text」プロパティに以下の開業を含む文字列を入力する。
    press left or right key to select a character
    press enter or space key to confirm
  2. 「Align」プロパティを「Center」にする。
  3. 「Uppercase」プロパティをオンにする。
  4. 「Theme Overrides」>「Fonts」>「Font」プロパティに、ファイルシステムドックから「res://fonts/poco/Poco.tres」をドラッグ&ドロップして適用する。
    *このリソースファイルは他のシーンでも利用しているのでリソースのプロパティはそのままにしておくこと。もし編集したい場合は必ずリソースを「ユニーク化」して他のノードのデザインに影響しないようにすること。
  5. 「CanvasItem」クラスの「Visibility」>「Modulate」プロパティで色を # 000000(黒)にする。

Captionプロパティ編集後


AnimatedSprite

このノードは、先に作成した 4 種類のプレイヤーキャラクターの「SpriteFrames」クラスのリソースファイルを「Frames」プロパティに適用させて、画面上に実際にキャラクターのスプライトを表示するために利用する。

  1. インスペクターで「Frames」プロパティに、ファイルシステムドックから先に作った「res://Player/MaskDude.tres」をドラッグ&ドロップして適用させる。最初は「MaskDude」のスプライトで表示させるためだ。
  2. 「Animation」プロパティは「idle」を選択する。これが一番、2Dワークスペース上で位置を把握しやすいはずだ。
  3. 「Position」プロパティは (192, 124) にする。これがちょうど左右のボタンの中間にあたる位置だ。

AnimatedSpriteプロパティ編集後


AnimationPlayer

このノードは「LeftButton」と「RightButton」をクリックした時に再生させる、ボタンが凹んだような簡単なアニメーションを作るのに利用する。

  1. インスペクターでのプロパティ編集は不要だ。
  2. アニメーションパネルを開く。
  3. まず左矢印ボタンのアニメーションを作成する。
  • アニメーション名: press_left
  • アニメーションの長さ(秒): 0.1
  • アニメーションループ: 無効
  • トラック:
    • 「LeftButton」ノード >「modulate」プロパティ
      • Time: 0秒 / Value: # ffffff / Easing: 1.00
      • Time: 0.05秒 / Value: # c8c8c8 / Easing: 1.00
      • Time: 0.1秒 / Value: # ffffff / Easing: 1.00
    • 「LeftButton」ノード >「rect_scale」プロパティ
      • Time: 0秒 / Value: (1, 1) / Easing: 1.00
      • Time: 0.05秒 / Value: (0.9, 0.9) / Easing: 1.00
      • Time: 0.1秒 / Value: (1, 1) / Easing: 1.00
  1. 次に右矢印ボタンのアニメーションを作成する。トラックのノードが違うこと以外は先に作成した「press_left」アニメーションとほとんど同じだ。
  • アニメーション名: press_right
  • アニメーションの長さ(秒): 0.1
  • アニメーションループ: 無効
  • トラック:
    • 「RightButton」ノード >「modulate」プロパティ
      • Time: 0秒 / Value: # ffffff / Easing: 1.00
      • Time: 0.05秒 / Value: # c8c8c8 / Easing: 1.00
      • Time: 0.1秒 / Value: # ffffff / Easing: 1.00
    • 「RightButton」ノード >「rect_scale」プロパティ
      • Time: 0秒 / Value: (1, 1) / Easing: 1.00
      • Time: 0.05秒 / Value: (0.9, 0.9) / Easing: 1.00
      • Time: 0.1秒 / Value: (1, 1) / Easing: 1.00


シングルトンを作る

シングルトンというのはプログラミング用語で、インスタンスが1つしかないように設計されたクラスのことで、他の複数のクラスで何らかの共通したプロパティやメソッドを利用する必要がある場合に用いられる。詳しい定義や仕組み、使い方などは、以下のリンク先のリソースをご参照いただきたい。

Memo:
Wikipedia - Singleton パターン
Godot 公式オンラインドキュメント - シングルトン(自動読み込み)

このチュートリアルでシングルトンを使用する理由は、4種類のプレイヤーキャラクターの「SpriteFrames」のリソースをシングルトンのプロパティとして用意しておけば、「CharacterSelect」シーンの「AnimatedSprite」ノードにも、「Level」シーン>「Player」>「AnimatedSprite」ノードにもいつでも利用できるようになるからだ。

仮に、「CharacterSelect」ノードのプロパティとして「SpriteFrames」のリソースを用意することの弊害が一つある。それは「CharacterSelect」インスタンスノードは用済みになったら「Game」シーンから解放(削除)させる、ということだ。解放してしまうと、リソースの参照先を見失って、プレイヤーキャラクターのスプライトにテクスチャを割り当てることができなくなる。その結果、プレイヤーキャラクターは透明人間状態になってしまう。

一方、シングルトンのスクリプトで「SpriteFrames」リソースを定義しておけば、他の複数のノードからいつでもそれを参照することができる。


シングルトンのスクリプトを作る

説明が長くなったが、ここからは以下の手順でシングルトンを実際に作成していこう。

  1. ファイルシステムドックで「res://」フォルダを右クリック>「新規スクリプト」を選択する。
    新規スクリプト作成
  2. 「スクリプト作成」ダイアログが表示されたら、継承元を必ず「Node」にして、ファイル名を「Global.gd」として作成する。
    スクリプト作成ダイアログ
  3. 「プロジェクト」メニュー>「プロジェクト設定」を選択する。
  4. 「自動読み込み(AutoLoad)」タブを開きパス入力欄の右側にあるフォルダアイコンをクリックする(Global.gdのパスを直接打ち込んでも良い)。
    自動読み込みタブ
  5. 「Global.gd」スクリプトを選択して開く。
    global.gd選択
  6. パス入力欄に「Global.gd」のファイルパスが入ったら右側の「追加」をクリックする。
    追加ボタン
  7. 自動読み込みに追加されたことを確認したら、ウインドウを閉じる。ちなみに「グローバル変数」とは他のどのスクリプトのどのスコープからでもアクセスできる変数のことだが、これを利用したいので有効のままで良い。
    追加後の画面

以上の作業で、シングルトン「Global.gd」スクリプトがゲーム開始時に一番最初に読み込まれ、常にどのシーンのどのノードのスクリプトからでもシングルトンのプロパティやメソッドにアクセスできるようになった。ゲーム開始時に自動読み込みされる時、自動的に「Node」が作成され、これに「Global.gd」スクリプトがアタッチされる仕組みだ。つまり、このスクリプトに記述するプロパティにアクセスしたければ、この「Node」のプロパティを参照するようコーディングすれば良い。なお、「Node」は自動的にスクリプトと同じ「Global」という名前になるので、簡単にアクセスできる。


シングルトンのスクリプトを編集する

ではシングルトンの「Global.gd」スクリプトを開き、以下のコードを記述しよう。

extends Node


var spriteframes = [
	preload("res://Player/MaskDude.tres"),
	preload("res://Player/NinjaFrog.tres"),
	preload("res://Player/PinkMan.tres"),
	preload("res://Player/VirtualGuy.tres")
]

プリロードされた4種類のプレイヤーキャラクターの「SpriteFrames」リソースを配列にまとめた。これで、プレイヤーキャラクターの4種類のリソースにどこからでも簡単にアクセスできる。



CharacterSelect にスクリプトをアタッチして編集する

「CharacterSelect」ノードに新規スクリプトをアタッチしよう。ファイルパスは「res://UI/CharacterSelect/CharacterSelect.gd」となるようにする。

では作成したシングルトンを利用しつつ、コーディングしていこう。

extends Control

# カスタムシグナル、引数に SpriteFrames リソースの参照を渡す
signal character_selected(sprite_frames)

# 現在キャラクター選択中のフラグを立てる(true)
var is_choosing = true
# AnimatedSprite のアニメーションの現在のフレーム番号を保持
var frames_num = 0
# キャラクターの名前の配列(要素の順番はシングルトンの SpriteFrames の配列に合わせる)
var characters = ["Mask Dude", "Ninja Frog", "Pink Man", "Virtual Guy"]

# シングルトンのノードの参照
onready var global = get_node("/root/Global")
# CharacterName ノードの参照
onready var char_name = $VBoxContainer/CharacterName
# LeftButton ノードの参照
onready var l_button = $VBoxContainer/HBoxContainer/LeftButton
# RightButton ノードの参照
onready var r_button = $VBoxContainer/HBoxContainer/RightButton
# AnimatedSprite ノードの参照
onready var sprite = $AnimatedSprite
# AnimationPlayer ノードの参照
onready var anim_player = $AnimationPlayer


func _ready():
	# 以下4行: AnimationPlayer のアニメーションで変更されるプロパティに初期値を設定
	l_button.modulate = Color(1, 1, 1, 1)
	l_button.rect_scale = Vector2(1, 1)
	r_button.modulate = Color(1, 1, 1, 1)
	r_button.rect_scale = Vector2(1, 1)
	# 以下のメソッドを呼ぶ(この後定義する)
	play_animations()


# _ready で呼ばれる: AnimatedSprite のアニメーションを制御する
func play_animations():
	# アニメーションの名前の配列(Array型を指定しないと PoolStringArray 型になる)
	var animations: Array = sprite.frames.get_animation_names()
	# 現在再生中のアニメーションの上記配列内の順番
	var anim_index = 0
  # アニメーションのループ回数をカウントするための変数
	var count = 0
	# デバッグ用
	print("animations array is ", animations)
	
	# アニメーションの名前の配列から不要な要素を削除するため配列の要素でループ
	for anim in animations:
		# アニメーションが fall か jump の場合は配列から削除(フレームが1つしかなく面白くないため)
		if anim == "fall" or anim == "jump":
			animations.remove(animations.find(anim))
			print("removed ", anim, " from animations array.")
	
	# 現在キャラクター選択中のフラグが立っている間はループする
	while is_choosing:
		# アニメーションの名前の配列から現在の再生中のアニメーションを指定して再生
		sprite.play(animations[anim_index])
		# 最後のアニメーションフレーム終了まで待機
		yield(sprite, "animation_finished")
		# アニメーションを一度停止する
		sprite.stop()
		# アニメーションが 4 周するまで while ループ
		if count < 4:
			count += 1
			continue
		# アニメーションが 4 周したらカウントを 0 にする
		count = 0
		# 現在再生中のアニメーションの番号が、アニメーションの配列の要素数 - 1 より小さい場合は 
		if anim_index < animations.size() - 1:
			# アニメーションの番号に 1 を加えて、次のアニメーションを再生するように仕込む
			anim_index += 1
		# 現在再生中のアニメーションの番号が、アニメーションの配列の要素数 - 1 になったら
		else:
			# 全てのアニメーションを再生したので、アニメーションの番号を 0 に戻して、最初のアニメーションに戻る
			anim_index = 0

少しコードが長い印象を持たれたかもしれないが、やっていることは単に「AnimatedSprite」ノードに設定済みのアニメーションを自動的に順次再生させているだけである。アニメーションを4周再生したら、次のアニメーションを再生し、全てのアニメーションを4回ずつ再生したらまた最初のアニメーションの再生から始めるようにwhileループを利用して制御している。


次はキャラクターを切り替える左右のボタンを押した時の制御を実装する。これにはシグナルを利用する。「LeftButton」ノードおよび「RightButton」ノードの「pressed()」シグナルを今編集している「CharacterSelect.gd」スクリプトに接続しよう。

接続して自動生成されたメソッドを以下のように編集する。ちなみに左ボタンが逆送り、右ボタンが順送りでキャラクターを切り替えるように実装している。

func _on_LeftButton_pressed():
	# AnimationPlayer の press_left アニメーションを再生
	anim_player.play("press_left")
	# シングルトンの SpriteFrames のリソースの配列から、
	# 一つ前のキャラクターのものを AnimatedSprite ノードの frames プロパティに適用する
	# CharacterName の text プロパティにその SpriteFrames に合致したキャラクターの名前を入れる
	# 今、配列の最初のリソースを適用している時は配列の最後のリソースを適用する
	if frames_num > 0:
		frames_num -= 1
		sprite.frames = global.spriteframes[frames_num]
		char_name.text = characters[frames_num]
	else:
		sprite.frames = global.spriteframes[global.spriteframes.size() -1]
		char_name.text = characters[characters.size() - 1]
		frames_num = global.spriteframes.size() -1


func _on_RightButton_pressed():
	# AnimationPlayer の press_right アニメーションを再生
	anim_player.play("press_right")
	# シングルトンの SpriteFrames のリソースの配列から、
	# 次のキャラクターのものを AnimatedSprite ノードの frames プロパティに適用する
	# CharacterName の text プロパティにその SpriteFrames に合致したキャラクターの名前を入れる
	# 今、配列の最後のリソースを適用している時は配列の最初のリソースを適用する
	if frames_num < global.spriteframes.size() -1:
		frames_num += 1
		sprite.frames = global.spriteframes[frames_num]
		char_name.text = characters[frames_num]
	else:
		sprite.frames = global.spriteframes[0]
		char_name.text = characters[0]
		frames_num = 0

キャラクターを切り替える操作のたびに「AnimatedSprite」の「Frames」プロパティに適切な「SpriteFrames」リソースを適用しているが、その時、シングルトンの「Global.gd」スクリプト内の配列spriteframes から当てはまる要素(SpriteFrames リソース)をピックアップして適用している。


最後にキーボードでの入力も受け付けられるようにコードを追加する。_inputメソッドは GDScript の組み込み関数で、これを利用してあらゆる入力に対する制御を実装できる。引数のeventは「InputEvent」クラスのオブジェクトで、キーボード入力やマウスクリックなどの入力イベントを指す。さまざまなメソッドが用意されているので、非常に便利である。

公式オンラインドキュメント:
Using InputEvent
void _input ( InputEvent event ) virtual
InputEvent

func _input(event):
	# 左矢印キーを押したら
	if event.is_action_pressed("ui_left"):
		# 左ボタンアイコンをクリックした時に呼ばれるメソッドを呼ぶ
		_on_LeftButton_pressed()
	# 右矢印キーを押したら
	elif event.is_action_pressed("ui_right"):
		# 右ボタンアイコンをクリックした時に呼ばれるメソッドを呼ぶ
		_on_RightButton_pressed()
	# スペースキーかエンターキーを押したら
	elif event.is_action_released("ui_accept"):
		# キャラクター選択中のフラグを無効にする
		is_choosing = false
		emit_signal("character_selected", global.spriteframes[frames_num])
		print("emitted signal: character_selected")
		queue_free()
		print("CharacterSelect scene is now freed.")

これで「CharacterSelect」シーンの作成は完了だ。



Game シーンに GameStart シーンと CharacterSelect シーンのインスタンスを追加する

それではここまでで作成した「GameStart」シーンと「CharacterSelect」シーンをインスタンス化して「Game」シーンに追加しよう。

…といういつもの流れとは少し異なる手順で進める。


Game シーンツリーを整理する

一旦「Game」シーンツリーを整理しよう。

「Game」シーンに UI 系のブランチが多いと、各ブランチのスクリプトで制御している入力操作が同時に処理されてしまう可能性があり面倒だ。例えばスタート画面でキーボードのスペースキーでキャラクター選択画面に遷移させただけのつもりが、次の画面のキャラクター選択が同時に確定してしまい、キャラクターを選ぶ間もなく一気にゲームプレイ画面に遷移する、というような問題だ。

スクリプトのコードを工夫すれば対処可能だが、この問題を完全に回避する分かりやす方法は、必要なノード以外をシーンツリー上に存在させないことだ。そこで、最初の画面である「GameStart」シーンのインスタンス、および入力操作の影響を受けない「HUD」シーンのインスタンスのみを「Game」シーンツリーに追加しておき、他のシーンのインスタンスはスクリプトで、必要な時に作成&追加することにする。

ということで、まずは「Game」シーンで、今回のチュートリアルで作成した「GameStart.tscn」のインスタンスを追加し、前回のチュートリアルで追加済みの「GameOver.tscn」のインスタンスノードを削除しよう。作業後の「Game」シーンツリーは以下のようになる。
Gameシーンツリー


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

ここから「Game.gd」スクリプトで、不要になったノードを削除したり、必要になったノードを追加したり、選択されたキャラクターの「SpriteFrames」リソースを「Level」ブランチ内のプレイヤーキャラクターに適用したり、という処理を実装していく。若干複雑かもしれないが、頑張ってやっていこう。

それでは「Game.gd」スクリプトを開いてほしい。そして、まずはプロパティの定義と_readyメソッドまで見ていこう。「# 追加」や「# 削除」とコメントしている行が更新箇所だ。

extends Node


export var current_level = 1
export var final_level = 2

var health: float = 100.0
var score: int = 0
var level: Node2D
var player: KinematicBody2D
# 選択されたキャラクターの SpriteFrames リソースの参照用
var char_frames: SpriteFrames # 追加
# UI ノードの参照
onready var ui_layer = $UI # 追加
# GameStart ノードの参照
onready var gamestart = $UI/GameStart # 追加
onready var hud = $UI/HUD
#onready var gameover = $UI/GameOver # Added @ Part 9 # 削除: シーンツリーから削除した


func _ready():
	#gameover.visible = false # Added @ Part 9 # 削除: シーンツリーから削除した
	# GameStart ノードが解放された時に発信されるシグナルを _on_GameStart_tree_exited メソッドに接続
	gamestart.connect("tree_exited", self ,"_on_GameStart_tree_exited") # 追加
	# 以下 4 行:ゲーム開始時に呼ぶ必要がなくなったので別のメソッドへ移動
	#add_level()
	#hud.update_health(health)
	#hud.update_score(score)
	#hud.update_level(current_level)

続いて、先にコード上で接続したシグナル「tree_exited()」の接続先メソッド_on_GameStart_tree_exitedを定義しよう。なお、このシグナルは「GameStart」シーンが解放された時に発信される。

func _on_GameStart_tree_exited():
	# CharacterSelect ノードのインスタンスを作成
	var char_select = load("res://UI/CharacterSelect/CharacterSelect.tscn").instance()
	# CharacterSelect ノードの character_selected カスタムシグナルを _on_CharacterSelect_character_selected メソッドに接続
	char_select.connect("character_selected", self ,"_on_CharacterSelect_character_selected")
	# UI ノードの子として CharacterSelect インスタンスノードを追加
	ui_layer.add_child(char_select)

さらに、今しがた「CharacterSelect」ノードから「character_selected」シグナルを接続した_on_CharacterSelect_character_selectedメソッドを編集していく。ちなみにこのシグナルは、キャラクター選択が確定したら発信される。このシグナルは自作したシグナルで、引数sprite_framesを持っている。これを接続先のメソッドの引数に渡す格好だ。

func _on_CharacterSelect_character_selected(sprite_frames):
	# このスクリプトのプロパティ char_frames に選択したキャラクターの SpriteFrames リソースを渡す
	char_frames = sprite_frames
	# 以下 4 行: _ready メソッドからこちらに移動させた
	add_level()
	hud.update_health(health)
	hud.update_score(score)
	hud.update_level(current_level)

「CharacterSelect.gd」スクリプトでは、「character_selected」シグナル発信後、まもなく「CharacterSelect」ノードは解放される。ここでシングルトンを作成したおかげで、char_framesプロパティは「CharacterSelect」ノードからバトンパスされたかのように、シングルトンの「SpriteFrames」リソースを参照した状態になる。

キャラクター選択画面の次はゲームプレイの開始となるので、もともと_readyメソッドで実行していたadd_levelメソッドでの「Level」シーンのインスタンス追加や「HUD」ノードの各コンポーネントの初期設定メソッドをこのタイミングで実行させている。


ここでadd_levelメソッドに大事な 1 行を加えることを忘れてはいけない。「Level」ノード>「Player」ノード>「AnimatedSprite」ノードの「Frames」プロパティに、キャラクター選択画面で選択したキャラクターの「SpriteFrames」リソースを適用するための 1 行だ。これにより、ゲームプレイ画面のプレイヤーキャラクターが、キャラクター選択画面で選んだキャラクターと同じ見た目になるわけだ。

func add_level():
	level = load("res://Levels/Level" + str(current_level) + ".tscn").instance()
	level.connect("tree_exited", self, "change_level")
	level.connect("player_dropped", self, "_on_Level_player_dropped")
	call_deferred("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 ノードの子 AnimatedSprite ノードに選択したキャラクターの SpriteFrames リソースを適用
	player.get_node("AnimatedSprite").frames = char_frames # 追加

このあとmanage_healthメソッドまでのコードは今までと変わりない。manage_healthメソッドの変更内容を見ていこう。

# 中略

func manage_health(damage):
	health -= damage # Moved from _on_Player_enemy_hit()
	print("Health updated: ", health) # Moved from _on_Player_enemy_hit()
	hud.update_health(health) # Moved from _on_Player_enemy_hit()

	if health <= 0:
		player.anim_player.play("die")
		yield(player.anim_player, "animation_finished")
		#gameover.visible = true # 削除: シーンツリーから削除しているため不要
		# GameOver シーンをインスタンス化
		var gameover = load("res://UI/GameOver/GameOver.tscn").instance() # 追加
		# UI ノードに GameOver シーンのインスタンスノードを追加
		ui_layer.add_child(gameover) # 追加
		print("Game over screen is shown up.")
		get_tree().paused = true
		print("Scene tree paused: ", get_tree().paused)

これまでは「GameOver」シーンのインスタンスは最初から「Game」シーンツリーに追加してあったが、さっき削除したので、visibleプロパティの有効化は不要となった。反対に「GameOver」シーンのインスタンスをスクリプト上で追加する必要があるので、そのためのコードを追加した。インスタンスを子ノードにする作業はたった 2 行のコードで実装できるのだ。(子ノードの順番の入れ替えが必要な場合はもう1行必要だ)。


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

最後にプロジェクトを実行して、今回作成した「GameStart」シーンと「CharacterSelect」シーンがプロジェクトの中でうまく動作するか確認しておこう。
プロジェクトを実行


以下のポイントで問題なさそうなので、ここで今回の作業を終えよう。

  • 最初にスタート画面が表示された。
  • スペースキーを押すとプレイヤーキャラクター選択画面が表示された。
  • プレイヤーキャラクター選択画面で、マウスでの左右ボタンの操作、キーボードでの左右矢印キーの操作によって、キャラクターの名前とスプライトの見た目が順番に切り替わった。
  • プレイヤーキャラクター選択画面でキャラクターはアニメーションし続けた。
  • プレイヤーキャラクター選択画面でスペースキーを押すとゲームプレイ画面に遷移した。
  • ゲームプレイ画面にはプレイヤーキャラクター選択画面で最後に選択したキャラクターが登場した。


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

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

GameStart.gd の全コード
extends Control # Added @ Part 10


onready var start_button = $VBoxContainer/StartVBox/StartButton
onready var anim_player = $AnimationPlayer

func _ready():
	start_button.modulate = Color(1, 1, 1, 1)
	start_button.rect_scale = Vector2(1, 1)


func _on_StartButton_button_up():
	#print("_on_StartButton_button_up called.")
	anim_player.play("press_start")
	yield(anim_player, "animation_finished")
	queue_free()
	print("GameStart scene is now freed.")


func _input(event):
	if event.is_action_released("ui_accept"):
		_on_StartButton_button_up()

CharacterSelect.gd の全コード
extends Control  # Added @ Part 10

signal character_selected(sprite_frames)

var is_choosing = true
var frames_num = 0
var characters = ["Mask Dude", "Ninja Frog", "Pink Man", "Virtual Guy"]

onready var global = get_node("/root/Global")
onready var char_name = $VBoxContainer/CharacterName
onready var l_button = $VBoxContainer/HBoxContainer/LeftButton
onready var r_button = $VBoxContainer/HBoxContainer/RightButton
onready var sprite = $AnimatedSprite
onready var anim_player = $AnimationPlayer


func _ready():
	l_button.modulate = Color(1, 1, 1, 1)
	l_button.rect_scale = Vector2(1, 1)
	r_button.modulate = Color(1, 1, 1, 1)
	r_button.rect_scale = Vector2(1, 1)
	play_animations()


func play_animations():
	var animations: Array = sprite.frames.get_animation_names()
	var anim_index = 0
	var count = 0
	#print("animations array is ", animations)
	
	for anim in animations:
		if anim == "fall" or anim == "jump":
			animations.remove(animations.find(anim))
			#print("removed ", anim, " from animations array.")
	
	while is_choosing:
		sprite.play(animations[anim_index])
		yield(sprite, "animation_finished")
		sprite.stop()
		if count < 4:
			count += 1
			continue	
		count = 0
		if anim_index < animations.size() - 1:
			anim_index += 1
		else:
			anim_index = 0


func _on_LeftButton_pressed():
	anim_player.play("press_left")
	if frames_num > 0:
		frames_num -= 1
		sprite.frames = global.spriteframes[frames_num]
		char_name.text = characters[frames_num]
	else:
		sprite.frames = global.spriteframes[global.spriteframes.size() - 1]
		char_name.text = characters[characters.size() - 1]
		frames_num = global.spriteframes.size() - 1


func _on_RightButton_pressed():
	anim_player.play("press_right")
	if frames_num < global.spriteframes.size() -1:
		frames_num += 1
		sprite.frames = global.spriteframes[frames_num]
		char_name.text = characters[frames_num]
	else:
		sprite.frames = global.spriteframes[0]
		char_name.text = characters[0]
		frames_num = 0


func _input(event):
	if event.is_action_pressed("ui_left"):
		_on_LeftButton_pressed()
	elif event.is_action_pressed("ui_right"):
		_on_RightButton_pressed()
	elif event.is_action_released("ui_accept"):
		is_choosing = false
		emit_signal("character_selected", global.spriteframes[frames_num])
		#print("emitted signal: character_selected")
		queue_free()
		print("CharacterSelect scene is now freed.")

Global.gd の全コード
extends Node # Added @ Part 10


var spriteframes = [
	preload("res://Player/MaskDude.tres"),
	preload("res://Player/NinjaFrog.tres"),
	preload("res://Player/PinkMan.tres"),
	preload("res://Player/VirtualGuy.tres")
]

Game.gd の全コード
extends Node # Added @ Part 7

export var current_level = 1
export var final_level = 2

var health: float = 100.0 # Added @ Part 8
var score: int = 0 # Added @ Part 8
var level: Node2D
var player: KinematicBody2D
var char_frames: SpriteFrames # Added @ Part 10

onready var ui_layer = $UI # Added @ Part 10
onready var gamestart = $UI/GameStart # Added @ Part 10
onready var hud = $UI/HUD # Added @ Part 8
#onready var gameover = $UI/GameOver # Added @ Part 9 # Removed @ Part 10


func _ready():
	#gameover.visible = false # Added @ Part 9 # Removed @ Part 10
	gamestart.connect("tree_exited", self ,"_on_GameStart_tree_exited") # Added @ Part 10


func _on_GameStart_tree_exited(): # Added @ Part 10
	var char_select = load("res://UI/CharacterSelect/CharacterSelect.tscn").instance()
	char_select.connect("character_selected", self ,"_on_CharacterSelect_character_selected") # Added @ Part 10
	ui_layer.add_child(char_select)


func _on_CharacterSelect_character_selected(sprite_frames): # Added @ Part 10
	char_frames = sprite_frames
	add_level() # Moved @ Part 10 # Modified @ Part 10
	hud.update_health(health) # Added @ Part 8 # Moved @ Part 10
	hud.update_score(score) # Added @ Part 8 # Moved @ Part 10
	hud.update_level(current_level) # Added @ Part 8 # Moved @ Part 10


func add_level(): # Modified @ Part 10
	level = load("res://Levels/Level" + str(current_level) + ".tscn").instance()
	level.connect("tree_exited", self, "change_level")
	level.connect("player_dropped", self, "_on_Level_player_dropped") # Added @ Part 9
	call_deferred("add_child", level) # Fixed error
	player = level.get_node("Player") # Added @ Part 8
	player.connect("enemy_hit", self, "_on_Player_enemy_hit") # Added @ Part 8
	player.connect("item_hit", self, "_on_Player_item_hit") # Added @ Part 8
	player.get_node("AnimatedSprite").frames = char_frames # Added @ Part 10


func change_level():
	if get_tree(): # Added @ Part 9
		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) # Added @ Part 8
			add_level()
		else:
			print("Game Clear! Congrats!")
			get_tree().quit()


func _on_Player_enemy_hit(damage): # Added @ Part 8
	manage_health(damage) # Modified @ Part 9


func _on_Player_item_hit(point): # Added @ Part 8
	score += point
	hud.update_score(score)


func _on_Level_player_dropped(): # Added @ Part 9
	manage_health(100)


func manage_health(damage): # Added @ Part 9
	health -= damage # Moved from _on_Player_enemy_hit() @ Part 9
	print("Health updated: ", health) # Moved from _on_Player_enemy_hit() @ Part 9
	hud.update_health(health) # Moved from _on_Player_enemy_hit() @ Part 9
	# Added @ Part 9
	if health <= 0:
		player.anim_player.play("die")
		yield(player.anim_player, "animation_finished")
		#gameover.visible = true # Removed @ Part 10
		var gameover = load("res://UI/GameOver/GameOver.tscn").instance() # Added @ Part 10
		ui_layer.add_child(gameover) # Added @ Part 10
		print("Game over screen is shown up.")
		get_tree().paused = true
		print("Scene tree paused: ", get_tree().paused)


おわりに

以上で Part 10 は完了だ。

今回はゲームスタート画面とその次のプレイヤーキャラクター選択画面を実装した。スタート画面に比べて、キャラクター選択画面の方は難しく感じられたかもしれない。シングルトンという何やら聞き慣れない単語も出てきたが、複数のシーンで同じデータにアクセスしたい時などに非常に有効な手段であると実感いただけたなら幸いだ。ゲーム開発に限らず、あらゆるオブジェクト指向プログラミングで多用されるデザインパターンなので、知っておいて損はないだろう。

さて、次回のチュートリアルでは、ゲームプレイ画面に背景テクスチャを追加し、カメラが移動するときに近景のタイルマップは早く動き、遠景の背景テクスチャはゆっくり動く、といういわゆるパララックスエフェクト(視差効果)を実装して、ユーザーエクスペリエンスを高めてみよう。

では次回もお楽しみに。