ゲームに「決定は A ボタン、キャンセルは B ボタンです」といった操作ガイドを出したいとき、毎回 UI シーンを個別に作り込むのは面倒ですよね。
さらに、メニュー画面・フィールド画面・バトル画面…とシーンが増えるほど、それぞれに似たようなガイド UI を複製して管理がカオスになりがちです。

Godot だと、つい「Control を継承した専用 UI シーン」を作って、各画面にインスタンスして…という実装をしがちですが、継承ベースだと柔軟に差し替えたり、複数のシーンで共通化するのが意外とつらいです。

そこで今回は、どのシーンにもポン付けできるコンポーネントとして、
現在の状況で押せるボタン(決定、キャンセル等)を画面隅に表示する
TutorialOverlay コンポーネントを作っていきましょう。

【Godot 4】いつでもポン付け操作ガイド!「TutorialOverlay」コンポーネント

TutorialOverlay はこんなコンセプトのコンポーネントです:

  • どのシーンにもアタッチできる Control ベースのコンポーネント
  • 「今この場面でプレイヤーが押せるボタン」を 画面隅に一覧表示
  • 「決定」「キャンセル」「メニュー」などの ラベルと入力アクション名をマッピング
  • アクションが有効なときだけ表示/無効なときは非表示、を コードから簡単に切り替え

つまり、入力ガイドを UI シーンとして継承・複製するのではなく
操作ガイドを表示する機能だけを持つコンポーネント」として、好きなシーンに合成していくイメージですね。


フルコード:TutorialOverlay.gd


extends Control
class_name TutorialOverlay
## TutorialOverlay
## 現在有効な操作(決定・キャンセルなど)を画面隅に表示するコンポーネント。
##
## 使い方の概要:
## - シーンのどこかに Control ノードとして追加
## - コードから enable_action("ui_accept", "決定") のように登録
## - 状況が変わったら disable_action("ui_cancel") などで更新
##
## レイアウトは VBoxContainer + HBoxContainer + Label のシンプル構成。
## UI スキンはテーマで変える前提にして、ここではロジックに集中しています。

@export_category("Layout")
@export_enum("TopLeft", "TopRight", "BottomLeft", "BottomRight")
var corner: String = "BottomRight" :
	set(value):
		corner = value
		if is_node_ready():
			_update_corner_anchoring()

@export var margin: Vector2 = Vector2(16, 16) :
	set(value):
		margin = value
		if is_node_ready():
			_update_corner_anchoring()

## 1つ1つの行のフォントサイズなどを調整したい場合のスケール
@export var row_scale: float = 1.0 :
	set(value):
		row_scale = value
		_update_rows_style()

@export_category("Behavior")
## 入力アクション名を表示するかどうか(例: "決定 (Enter)" のように)
@export var show_action_name: bool = true :
	set(value):
		show_action_name = value
		_refresh_all_rows()

## ゲームパッド・キーボードなどから取得したボタン名を表示するか
## ここでは簡易的に InputMap の最初のキーを文字列化しています。
@export var show_first_input_event: bool = true :
	set(value):
		show_first_input_event = value
		_refresh_all_rows()

## 決定・キャンセルなどの「ラベル名」をキーにして管理するか、
## それともアクション名をキーにして管理するかの切り替え。
@export_enum("ByLabel", "ByAction")
var key_mode: String = "ByAction"

## 内部で使うコンテナへの参照
var _root_container: VBoxContainer
## 有効な行を管理する辞書
## key: String (label or action_name), value: HBoxContainer
var _rows := {}

func _ready() -> void:
	## このコンポーネント自体は画面隅に固定表示される Control として動作
	mouse_filter = MOUSE_FILTER_IGNORE
	_create_ui()
	_update_corner_anchoring()


func _create_ui() -> void:
	## すでに子ノードがあれば流用するが、なければ自動生成
	if get_child_count() == 0:
		_root_container = VBoxContainer.new()
		add_child(_root_container)
		_root_container.name = "Rows"
	else:
		_root_container = get_child(0) as VBoxContainer
		if _root_container == null:
			push_warning("TutorialOverlay: 既存の子ノードが VBoxContainer ではありません。自動で作り直します。")
			for c in get_children():
				remove_child(c)
			_root_container = VBoxContainer.new()
			add_child(_root_container)
			_root_container.name = "Rows"

	_root_container.alignment = BoxContainer.ALIGNMENT_END
	_root_container.grow_horizontal = Control.GROW_DIRECTION_BEGIN
	_root_container.grow_vertical = Control.GROW_DIRECTION_BEGIN

	_update_rows_style()


func _update_corner_anchoring() -> void:
	## アンカーとマージンを corner 設定から決定
	match corner:
		"TopLeft":
			anchor_left = 0.0
			anchor_top = 0.0
			anchor_right = 0.0
			anchor_bottom = 0.0
			## 左上からのオフセット
			offset_left = margin.x
			offset_top = margin.y
			offset_right = margin.x
			offset_bottom = margin.y
		"TopRight":
			anchor_left = 1.0
			anchor_top = 0.0
			anchor_right = 1.0
			anchor_bottom = 0.0
			offset_left = -margin.x
			offset_top = margin.y
			offset_right = -margin.x
			offset_bottom = margin.y
		"BottomLeft":
			anchor_left = 0.0
			anchor_top = 1.0
			anchor_right = 0.0
			anchor_bottom = 1.0
			offset_left = margin.x
			offset_top = -margin.y
			offset_right = margin.x
			offset_bottom = -margin.y
		"BottomRight":
			anchor_left = 1.0
			anchor_top = 1.0
			anchor_right = 1.0
			anchor_bottom = 1.0
			offset_left = -margin.x
			offset_top = -margin.y
			offset_right = -margin.x
			offset_bottom = -margin.y


func _update_rows_style() -> void:
	if not is_instance_valid(_root_container):
		return
	for row in _rows.values():
		if row is HBoxContainer:
			row.scale = Vector2(row_scale, row_scale)


func _refresh_all_rows() -> void:
	## 表示オプションが変わったときに全行を作り直す
	for key in _rows.keys():
		var row := _rows[key] as HBoxContainer
		if not is_instance_valid(row):
			continue
		var meta := row.get_meta("overlay_meta")
		if typeof(meta) == TYPE_DICTIONARY:
			_update_row_text(row, meta)


func _create_row(label_text: String, action_name: String) -> HBoxContainer:
	var row := HBoxContainer.new()
	row.alignment = BoxContainer.ALIGNMENT_END
	row.scale = Vector2(row_scale, row_scale)

	## 左側: 操作ラベル(例: 決定 / キャンセル)
	var label := Label.new()
	label.name = "Label"
	label.text = label_text
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT

	## 右側: アクション名やキーの情報
	var detail := Label.new()
	detail.name = "Detail"
	detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT

	row.add_child(label)
	row.add_child(detail)

	## メタデータに情報を保持しておき、後から再構築できるようにする
	row.set_meta("overlay_meta", {
		"label": label_text,
		"action": action_name,
	})

	_update_row_text(row, row.get_meta("overlay_meta"))

	return row


func _update_row_text(row: HBoxContainer, meta: Dictionary) -> void:
	var label := row.get_node("Label") as Label
	var detail := row.get_node("Detail") as Label

	var label_text: String = meta.get("label", "")
	var action_name: String = meta.get("action", "")

	label.text = label_text

	var detail_parts: Array[String] = []
	if show_action_name and action_name != "":
		detail_parts.append(action_name)

	if show_first_input_event and action_name != "":
		var events := InputMap.action_get_events(action_name)
		if events.size() > 0:
			var first_event := events[0]
			detail_parts.append(_event_to_short_string(first_event))

	detail.text = detail_parts.join(" / ")


func _event_to_short_string(event: InputEvent) -> String:
	## ここではシンプルに Keyboard / JoypadButton / MouseButton を簡易表示
	if event is InputEventKey:
		var e := event as InputEventKey
		return OS.get_keycode_string(e.keycode)
	elif event is InputEventJoypadButton:
		var jb := event as InputEventJoypadButton
		return "Pad %d" % jb.button_index
	elif event is InputEventMouseButton:
		var mb := event as InputEventMouseButton
		match mb.button_index:
			MOUSE_BUTTON_LEFT:
				return "Mouse L"
			MOUSE_BUTTON_RIGHT:
				return "Mouse R"
			MOUSE_BUTTON_MIDDLE:
				return "Mouse M"
			_:
				return "Mouse %d" % mb.button_index
	else:
		return "Input"


## --- 公開 API -------------------------------------------------------------

## 操作を有効にして表示する
## label_text: 画面に表示するラベル(例: "決定", "キャンセル")
## action_name: InputMap に登録されているアクション名(例: "ui_accept")
func enable_action(action_name: String, label_text: String) -> void:
	var key: String = _make_key(action_name, label_text)

	if _rows.has(key):
		## すでに行がある場合は更新だけ
		var row := _rows[key] as HBoxContainer
		if is_instance_valid(row):
			row.set_meta("overlay_meta", {
				"label": label_text,
				"action": action_name,
			})
			_update_row_text(row, row.get_meta("overlay_meta"))
		return

	var row := _create_row(label_text, action_name)
	_root_container.add_child(row)
	_rows[key] = row


## 操作を無効にして非表示にする
func disable_action(action_name: String, label_text: String = "") -> void:
	var key: String = _make_key(action_name, label_text)
	if not _rows.has(key):
		return
	var row := _rows[key] as HBoxContainer
	if is_instance_valid(row):
		row.queue_free()
	_rows.erase(key)


## すべての表示をクリア
func clear_all() -> void:
	for row in _rows.values():
		if is_instance_valid(row):
			row.queue_free()
	_rows.clear()


## 一時的に全体を隠す(Visibility)
func set_overlay_visible(visible: bool) -> void:
	self.visible = visible


func _make_key(action_name: String, label_text: String) -> String:
	if key_mode == "ByLabel":
		return label_text
	else:
		return action_name

使い方の手順

ここからは、実際に TutorialOverlay をシーンに組み込む手順を見ていきましょう。

手順①:スクリプトを用意する

  1. 上記のコードを res://components/ui/TutorialOverlay.gd などに保存します。
  2. Godot エディタを再読み込みすると、ノード追加ダイアログで「TutorialOverlay」クラスを選べるようになります。

手順②:プレイヤーシーンにアタッチする例

たとえば、アクションゲームのプレイヤーに「決定」「キャンセル」を表示したい場合:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TutorialOverlay (Control)
  1. Player シーンを開きます。
  2. 子ノードとして Control を追加し、スクリプトに TutorialOverlay.gd をアタッチします。もしくは、クラス一覧から直接 TutorialOverlay を追加します。
  3. TutorialOverlay のインスペクタで、corner = BottomRightmargin = (16, 16) など好みのレイアウトを設定します。

プレイヤーのスクリプトからは、こんな感じで使います:


# Player.gd (例)
extends CharacterBody2D

@onready var tutorial_overlay: TutorialOverlay = $TutorialOverlay

func _ready() -> void:
	# フィールドでは「決定」と「メニュー」だけ有効
	tutorial_overlay.enable_action("ui_accept", "決定")
	tutorial_overlay.enable_action("ui_cancel", "キャンセル")
	tutorial_overlay.enable_action("ui_menu", "メニュー")

func _on_dialog_started() -> void:
	# 会話中は「決定」だけに絞る
	tutorial_overlay.clear_all()
	tutorial_overlay.enable_action("ui_accept", "次へ")

func _on_dialog_finished() -> void:
	# 元の操作ガイドに戻す
	tutorial_overlay.clear_all()
	tutorial_overlay.enable_action("ui_accept", "決定")
	tutorial_overlay.enable_action("ui_cancel", "キャンセル")
	tutorial_overlay.enable_action("ui_menu", "メニュー")

手順③:メニュー画面で使う例

同じコンポーネントを、メインメニューにも再利用できます。

MainMenu (Control)
 ├── Panel
 │   └── VBoxContainer
 │       ├── ButtonStart
 │       └── ButtonExit
 └── TutorialOverlay (Control)

# MainMenu.gd
extends Control

@onready var tutorial_overlay: TutorialOverlay = $TutorialOverlay

func _ready() -> void:
	# メニューでは「決定」と「キャンセル」だけ表示
	tutorial_overlay.clear_all()
	tutorial_overlay.enable_action("ui_accept", "決定")
	tutorial_overlay.enable_action("ui_cancel", "戻る")

このように、UI 全体を継承して作り直すのではなく
「操作ガイド」という機能だけを TutorialOverlay コンポーネントとして、どのシーンにも合成できるのがポイントです。

手順④:動く床やギミックにもポン付けする例

「このスイッチは A ボタンで押せます」みたいなガイドも、同じコンポーネントで表現できます。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── Area2D
 │   └── CollisionShape2D
 └── TutorialOverlay (Control)

# MovingPlatform.gd
extends Node2D

@onready var tutorial_overlay: TutorialOverlay = $TutorialOverlay
@onready var area: Area2D = $Area2D

func _ready() -> void:
	tutorial_overlay.clear_all()
	tutorial_overlay.set_overlay_visible(false)
	area.body_entered.connect(_on_body_entered)
	area.body_exited.connect(_on_body_exited)

func _on_body_entered(body: Node) -> void:
	if body.name == "Player":
		tutorial_overlay.set_overlay_visible(true)
		tutorial_overlay.enable_action("ui_accept", "乗る")

func _on_body_exited(body: Node) -> void:
	if body.name == "Player":
		tutorial_overlay.clear_all()
		tutorial_overlay.set_overlay_visible(false)

こうしておくと、プレイヤーが近づいたときだけ操作ガイドを表示できます。
「動く床」「宝箱」「スイッチ」など、あらゆるインタラクト可能オブジェクトに合成できるのが、コンポーネント指向の強みですね。


メリットと応用

  • シーン構造がスッキリ
    「操作ガイド用 UI シーン」を継承ツリーで量産する必要がなくなり、各シーンには TutorialOverlay を 1 個置くだけで済みます。
  • 使い回しがしやすい
    プレイヤー、敵、ギミック、メニュー…どこでも同じ API(enable_action / disable_action / clear_all)で扱えます。
  • 入力スキームの変更に強い
    InputMap の設定を変えても、action_get_events() から自動的にキー名を拾ってくれるので、ガイドテキストをいちいち手で直さなくて済む構造にできます。
  • レベルデザインが楽
    レベルデザイナーは「どの場面でどの操作を有効にするか」だけを考えればよく、UI の細かい作り込みを毎回やらなくて済みます。

さらに、コンポーネントを差し替えるだけで挙動を変えられるのも合成の良いところです。
例えば「チュートリアル中だけ、点滅アニメーションをする FancyTutorialOverlay」を別コンポーネントとして用意し、シーンによって使い分ける、という設計もやりやすくなります。

改造案:フェードイン/フェードアウトを追加する

例えば、操作ガイドの表示/非表示を「パッと切り替える」のではなく、ふわっとフェードさせたい場合は、set_overlay_visible() をアニメーション付きに差し替えるのもアリですね。


# TutorialOverlay.gd に追加する改造例
func fade_overlay(visible: bool, duration: float = 0.2) -> void:
	var tween := create_tween()
	if visible:
		self.visible = true
		self.modulate.a = 0.0
		tween.tween_property(self, "modulate:a", 1.0, duration)
	else:
		self.modulate.a = 1.0
		tween.tween_property(self, "modulate:a", 0.0, duration)
		tween.tween_callback(Callable(self, "_on_fade_out_finished"))

func _on_fade_out_finished() -> void:
	self.visible = false

これを使えば、先ほどの MovingPlatform などで


tutorial_overlay.fade_overlay(true)
# ...
tutorial_overlay.fade_overlay(false)

のように呼び出すだけで、視認性の高いチュートリアル演出が作れます。
このように、コンポーネント指向で作っておくと、機能ごとにスクリプトを差し替えたり、拡張したりするのがとても楽になりますね。