Godot 4 でチュートリアル用のUIを作るとき、
「プレイヤーにどのキーを押してほしいか」を画面に出したい場面って多いですよね。

  • InputMap にアクションは登録してあるけど、対応するキーを毎回 UI にベタ書きするのは面倒
  • 「Jump」はキーボードだと Space、ゲームパッドだと Aボタン…みたいに入力デバイスごとに表示を変えたい
  • チュートリアルシーンごとに Control ノードを作って、ラベルやアイコンをペタペタ貼るとシーン階層がどんどん深くなる

こういうとき、「チュートリアル用の入力表示ロジック」をシーンにベタ書きしたり、特定の UI ノードを継承して巨大なスクリプトにしがちです。
でもそれだと、

  • 別のシーンで同じ仕組みを使いたいときにコピペ地獄
  • 「このシーンだけ表示位置をちょっと変えたい」みたいな調整がしづらい
  • UI ノードの継承ツリーが複雑になっていく

そこで今回は、どのノードにもポン付けできる「入力表示コンポーネント」として、
InputVisualizer を用意してみましょう。

親ノード(例えば Control)にアタッチしておくだけで、
押されたアクションに対応するキーアイコンを、自動で親の中に表示してくれる仕組みです。
継承ではなく「合成(Composition)」で、入力チュートリアル UI を後付けできるようにします。


【Godot 4】押したキーがその場で光る!「InputVisualizer」コンポーネント

以下が、今回のコンポーネントのフルコードです。
class_name InputVisualizer を定義しているので、どのシーンからも「通常のノード」として追加できます。


extends Node
class_name InputVisualizer
## 入力されたアクションに対応するキーアイコンを、親ノード内に表示するコンポーネント。
##
## 想定親ノード:
##   - Control / CanvasItem / Node2D など
##   - 子に Sprite2D / TextureRect / Label などを自動生成して表示します。
##
## 使い方の流れ:
##   1. 任意のノードに InputVisualizer をアタッチ
##   2. editor でアクション名と対応アイコンを設定
##   3. ゲーム中にアクションが押されると、親の中にアイコンがポップ表示される

@tool
@icon("res://icon.svg") # 任意。エディタ上の見た目用。

# --- 設定パラメータ -------------------------------------------------------

## 監視する InputMap のアクション名リスト。
## 例: ["move_left", "move_right", "jump"]
@export var actions: Array[StringName] = []

## アクションごとのアイコンテクスチャ辞書。
## キー: アクション名, 値: 表示したい Texture2D
## 例: {"jump": preload("res://ui/icons/space_key.png")}
@export var action_icons: Dictionary = {}

## アクションごとのラベルテキスト辞書。
## アイコンがない場合や、補足テキストとして利用。
## 例: {"jump": "Space", "attack": "J"}
@export var action_labels: Dictionary = {}

## アイコンが設定されていないアクション用のデフォルトテクスチャ。
@export var default_icon: Texture2D

## 表示するノードのタイプ。
## "TextureRect" か "Sprite2D" か "Label" を選択可能。
@export_enum("TextureRect", "Sprite2D", "Label") var visual_type := "TextureRect"

## 表示位置の基準。
## - "CENTER": 親の中心
## - "TOP": 親の上部中央
## - "BOTTOM": 親の下部中央
## - "CUSTOM": custom_offset を基準にする
@export_enum("CENTER", "TOP", "BOTTOM", "CUSTOM") var anchor := "CENTER"

## anchor が CUSTOM のときに使うオフセット。
## 親ノードのローカル座標系で指定します。
@export var custom_offset: Vector2 = Vector2.ZERO

## アイコンやラベルのスケール。
@export var scale: Vector2 = Vector2.ONE

## ボタンを押している間だけ表示するかどうか。
## false の場合は、押した瞬間に一定時間表示してフェードアウトします。
@export var hold_to_show := true

## hold_to_show = false のとき、表示しておく時間(秒)。
@export_range(0.1, 5.0, 0.1) var show_duration := 0.7

## フェードアウトにかける時間(秒)。
@export_range(0.05, 2.0, 0.05) var fade_duration := 0.2

## 1つのアクションにつき、同時にいくつまでインスタンスを表示するか。
## 連打時の「ポップ」演出をどこまで許容するかの制御。
@export_range(1, 10, 1) var max_instances_per_action := 3

## 生成するビジュアルノードに付けるグループ名。
## デバッグや別スクリプトからの制御に使えます。
@export var visual_node_group := "input_visualizer_icon"

# --- 内部状態 -------------------------------------------------------------

## アクション名ごとに、現在表示中のノード一覧を保持。
var _active_visuals: Dictionary = {} # {StringName: Array[CanvasItem]}

## editor 上でプレビュー的に表示したノードを管理(@tool 用)
var _editor_preview_nodes: Array[Node] = []

# --- ライフサイクル ------------------------------------------------------

func _ready() -> void:
	# ゲーム中のみロジックを動かす(エディタ上ではプレビューだけ)
	if Engine.is_editor_hint():
		_update_editor_preview()
		return

	# 実行時はプレビューを消しておく
	_clear_editor_preview()

	# 管理用ディクショナリを初期化
	for action in actions:
		_active_visuals[action] = []


func _process(delta: float) -> void:
	if Engine.is_editor_hint():
		# エディタ上では何もしない(@tool で Inspector の変更に反応するだけ)
		return

	# 各アクションの入力状態を監視
	for action in actions:
		# 押された瞬間
		if Input.is_action_just_pressed(action):
			_on_action_pressed(action)
		# 離された瞬間
		if Input.is_action_just_released(action):
			_on_action_released(action)


func _notification(what: int) -> void:
	if not Engine.is_editor_hint():
		return

	match what:
		NOTIFICATION_EDITOR_PROPERTY_CHANGED:
			# Inspector の値が変わったらプレビューを更新
			_update_editor_preview()
		NOTIFICATION_EXIT_TREE:
			_clear_editor_preview()

# --- 入力イベント処理 -----------------------------------------------------

func _on_action_pressed(action: StringName) -> void:
	# 新しいビジュアルノードを生成
	var visual := _create_visual_node(action)
	if visual == null:
		return

	# 親にぶら下げる
	get_parent().add_child(visual)
	visual.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else null

	# 表示位置とスケールをセット
	_apply_transform_to_visual(visual)

	# 管理リストに追加
	if not _active_visuals.has(action):
		_active_visuals[action] = []
	_active_visuals[action].append(visual)

	# インスタンス数が上限を超えたら、古いものから消す
	var visuals_for_action: Array = _active_visuals[action]
	while visuals_for_action.size() > max_instances_per_action:
		var to_remove: CanvasItem = visuals_for_action.pop_front()
		if is_instance_valid(to_remove):
			to_remove.queue_free()

	# 表示方式に応じて挙動を変える
	if hold_to_show:
		# 押している間はそのまま表示。離されたときに消す。
		# ここでは特に何もしない。
		pass
	else:
		# 一定時間表示してからフェードアウト
		_fade_out_and_free(visual, show_duration, fade_duration)


func _on_action_released(action: StringName) -> void:
	if not hold_to_show:
		# 「押している間だけ表示」モードでなければ、離した瞬間の処理は不要
		return

	if not _active_visuals.has(action):
		return

	var visuals_for_action: Array = _active_visuals[action]
	# 離されたら、全部フェードアウトさせる
	for visual in visuals_for_action:
		if is_instance_valid(visual):
			_fade_out_and_free(visual, 0.0, fade_duration)

	# 管理リストをクリア
	_active_visuals[action] = []

# --- ビジュアルノード生成 -------------------------------------------------

## アクションに対応するビジュアルノードを生成する。
## 設定に応じて TextureRect / Sprite2D / Label を返す。
func _create_visual_node(action: StringName) -> CanvasItem:
	var node: CanvasItem

	match visual_type:
		"TextureRect":
			var tex_rect := TextureRect.new()
			tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
			tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
			tex_rect.size_flags_vertical = Control.SIZE_SHRINK_CENTER
			tex_rect.texture = _get_icon_for_action(action)
			node = tex_rect
		"Sprite2D":
			var sprite := Sprite2D.new()
			sprite.texture = _get_icon_for_action(action)
			node = sprite
		"Label":
			var label := Label.new()
			label.text = _get_label_for_action(action)
			label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
			label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
			node = label
		_:
			push_warning("Unknown visual_type: %s" % visual_type)
			return null

	node.name = "InputVisual_%s" % String(action)
	node.add_to_group(visual_node_group)
	node.modulate.a = 1.0

	return node


## アクションに対応するアイコンテクスチャを取得。
func _get_icon_for_action(action: StringName) -> Texture2D:
	if action_icons.has(action) and action_icons[action] is Texture2D:
		return action_icons[action]
	return default_icon


## アクションに対応するラベルテキストを取得。
func _get_label_for_action(action: StringName) -> String:
	if action_labels.has(action):
		return str(action_labels[action])
	# ラベルが未設定ならアクション名をそのまま使う
	return String(action)


## 親ノードの種類に応じて、ビジュアルノードの位置・スケールを設定。
func _apply_transform_to_visual(visual: CanvasItem) -> void:
	var parent_node := get_parent()

	# 位置(ローカル座標)
	var pos := Vector2.ZERO
	match anchor:
		"CENTER":
			pos = _get_parent_center(parent_node)
		"TOP":
			pos = _get_parent_center(parent_node) + Vector2(0, -32)
		"BOTTOM":
			pos = _get_parent_center(parent_node) + Vector2(0, 32)
		"CUSTOM":
			pos = custom_offset
		_:
			pos = _get_parent_center(parent_node)

	# Control 系か Node2D 系かで設定方法を分ける
	if visual is Control:
		var c := visual as Control
		c.position = pos
		c.pivot_offset = c.size * 0.5
		c.scale = scale
	else:
		# Sprite2D / Label など CanvasItem の場合
		if "position" in visual:
			visual.position = pos
		if "scale" in visual:
			visual.scale = scale


## 親ノードの中心位置をざっくり取得する。
## Control なら rect_size / 2、Node2D なら (0,0) を中心とみなす。
func _get_parent_center(parent_node: Node) -> Vector2:
	if parent_node is Control:
		var c := parent_node as Control
		return c.size * 0.5
	# Node2D / その他はローカル原点を中心とみなす
	return Vector2.ZERO

# --- フェードアウト処理 ---------------------------------------------------

## 指定時間待ってからフェードアウトし、queue_free する。
func _fade_out_and_free(visual: CanvasItem, delay: float, duration: float) -> void:
	if not is_instance_valid(visual):
		return

	var tween := visual.create_tween()
	if delay > 0.0:
		tween.tween_interval(delay)
	tween.tween_property(visual, "modulate:a", 0.0, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
	tween.tween_callback(Callable(visual, "queue_free"))

# --- エディタプレビュー用 (@tool) ----------------------------------------

## Inspector でパラメータを変えたときに、簡易プレビューを出す。
func _update_editor_preview() -> void:
	_clear_editor_preview()

	if get_parent() == null:
		return

	# 最初のアクションだけをプレビュー表示
	if actions.is_empty():
		return

	var action := actions[0]
	var visual := _create_visual_node(action)
	if visual == null:
		return

	get_parent().add_child(visual)
	visual.owner = get_tree().edited_scene_root
	_apply_transform_to_visual(visual)
	_editor_preview_nodes.append(visual)


func _clear_editor_preview() -> void:
	for n in _editor_preview_nodes:
		if is_instance_valid(n):
			n.queue_free()
	_editor_preview_nodes.clear()

使い方の手順

ここでは、典型的な「チュートリアルUI」3パターンを例に、InputVisualizer の使い方を見ていきましょう。

例1: プレイヤーの頭上に「ジャンプキー」を表示する

プレイヤーが初めてジャンプできる場所で、「Space を押してジャンプ!」みたいなアイコンをプレイヤーの頭上に出したいケースです。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── JumpHint (Node)  ← ここに InputVisualizer をアタッチ
  1. InputMap を設定する
    プロジェクト設定 > Input Map で、例えば以下のようなアクションを用意しておきます。
    move_left : A / Left
    move_right: D / Right
    jump : Space / Gamepad South Button
  2. JumpHint ノードを追加
    Player シーン内で、子ノードとして Node を追加し、名前を JumpHint にします。
    この JumpHint に、先ほどの InputVisualizer スクリプトをアタッチします。
  3. Inspector で InputVisualizer を設定
    • actions: ["jump"]
    • visual_type: TextureRect
    • action_icons: {"jump": preload("res://ui/icons/space_key.png")}
    • anchor: TOP(プレイヤーの頭上に出したい場合)
    • scale: Vector2(1, 1)(必要に応じて調整)
    • hold_to_show: false(押した瞬間だけポップさせたいなら)
    • show_duration: 0.8
    • fade_duration: 0.2
  4. ゲームを実行して確認
    実行中に Input.is_action_just_pressed("jump") が呼ばれるたび、
    プレイヤーの頭上に Space キーのアイコンがポップ表示されます。

例2: 画面端の「操作説明パネル」にキーアイコンを並べる

画面の右下に「移動: A / D, ジャンプ: Space」みたいなパネルを作る場合です。

HUD (CanvasLayer)
 └── Control
      ├── PanelContainer
      │    └── VBoxContainer
      │         ├── HBoxContainer
      │         │    ├── Label ("Move")
      │         │    └── MoveHint (Node) ← InputVisualizer
      │         └── HBoxContainer
      │              ├── Label ("Jump")
      │              └── JumpHint (Node) ← InputVisualizer
      └── ...

MoveHint / JumpHint ノードに InputVisualizer をアタッチし、

  • MoveHint: actions = ["move_left", "move_right"]
  • JumpHint: actions = ["jump"]

のように設定します。
anchor = CENTER にしておけば、親の HBoxContainer の中央にアイコンが来るので、レイアウトも崩れにくいですね。

例3: 動く床の上に「乗ったらボタンを教える」チュートリアル

「この床に乗ったら、ジャンプボタンを教える」みたいな一時的チュートリアルも、InputVisualizer を合成するだけで実現できます。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── InputHint (Node) ← InputVisualizer

InputHintInputVisualizer を付けておき、普段は set_process(false) で止めておきます。
プレイヤーが床に乗ったら set_process(true) して、ジャンプボタンをしばらくの間だけ表示させる、などですね。


# MovingPlatform.gd の例
extends Node2D

@onready var input_hint: InputVisualizer = $InputHint

func _on_body_entered(body: Node) -> void:
	if body.name == "Player":
		input_hint.set_process(true)

func _on_body_exited(body: Node) -> void:
	if body.name == "Player":
		input_hint.set_process(false)

メリットと応用

InputVisualizer をコンポーネントとして分離しておくと、かなり嬉しいポイントがあります。

  • シーン構造がスッキリ
    プレイヤー / 敵 / ギミック それぞれが、自分のロジックを持ちながら、
    「入力チュートリアル表示」という共通機能だけを後付けできます。
    巨大な HUD シーンや、継承だらけの UI 基底クラスを作らなくて済みます。
  • 使い回しが簡単
    どのシーンにも「Node を1つ足して InputVisualizer をアタッチする」だけで再利用できます。
    アクション名とアイコンのマッピングも、Dictionary にまとめておけるので管理が楽です。
  • レベルデザイン時の微調整がしやすい
    「このステージだけ、アイコンをもう少し上に」なども、anchorcustom_offset をいじるだけ。
    親シーンの構造を壊さずに UI の見た目だけを調整できます。
  • 入力デバイスごとの表示切り替えにも対応しやすい
    例えば、ゲームパッド接続時は action_icons を差し替える、
    あるいは visual_type = "Label" にして「Press A」/「Press Space」などのテキストを出し分ける、といった拡張もしやすいです。

「入力チュートリアルを出したいノードに、後からコンポーネントを足すだけ」
という構成にしておくと、継承ツリーに縛られず、レベルデザインのスピードがかなり上がるはずです。

改造案:入力デバイスによってアイコンを出し分ける

例えば、「キーボードのときは Space アイコン、ゲームパッドのときは A ボタンアイコン」にしたい場合、
こんな感じで _get_icon_for_action を拡張してみると良いですね。


func _get_icon_for_action(action: StringName) -> Texture2D:
	# 例: キーボード用 / ゲームパッド用で辞書を分ける
	var using_gamepad := Input.get_connected_joypads().size() > 0

	if using_gamepad:
		if action_icons.has("%s_gamepad" % action):
			return action_icons["%s_gamepad" % action]

	# ゲームパッド用がなければ通常アイコンを使う
	if action_icons.has(action) and action_icons[action] is Texture2D:
		return action_icons[action]

	return default_icon

このように、「入力の見せ方」ロジックを InputVisualizer に閉じ込めておくと、
プレイヤー本体や HUD シーンはシンプルなまま、入力周りの UX をどんどん盛っていけます。
ぜひ、自分のプロジェクト用にカスタマイズしてみてください。