スマホ向けのGodotゲームを作り始めると、まずぶつかるのが「仮想ボタンどうする問題」ですね。
Godot標準だと TouchScreenButton を直接シーンに置いて、毎回テクスチャやアクション名を設定して…と、シーンごとに似たようなノードを量産しがちです。

さらに、

  • プレイヤーシーンごとにボタンを持たせると、UIのレイアウト変更が地獄
  • シーンをまたいで同じボタンを使いたいのに、コピペ管理になりがち
  • テスト用に「一時的に隠したい」ときも、各シーンを開いてチェックを外す必要がある

つまり、「仮想ボタンの実装」がゲームロジックとベタっとくっついてしまい、継承と深いノード階層に引きずられる構造になりがちなんですよね。

そこで今回は、どのシーンにもポン付けできる「VirtualButtons」コンポーネントを用意して、

  • 画面に A/B ボタンを表示
  • TouchScreenButton を内部で扱いつつ、Input のアクションとして送出
  • シーン構造はシンプルなまま、UIだけを合成(Composition)で付け足す

というスタイルにしてみましょう。

【Godot 4】スマホ向け入力をコンポーネント化!「VirtualButtons」コンポーネント

以下は、CanvasLayer ベースで動く「VirtualButtons」コンポーネントのフルコードです。
A/Bボタンの表示位置やアクション名、半透明化などを @export で柔軟に変更できるようにしてあります。

GDScriptフルコード


extends CanvasLayer
class_name VirtualButtons
"""
スマホ画面に A / B 仮想ボタンを表示し、
TouchScreenButton を使って InputAction を送出するコンポーネント。

どのシーンにも「合成」して使えるように、ゲームロジックとは独立させています。
"""

# --- 基本設定 -------------------------------------------------------------

@export_category("General")
## 仮想ボタン全体を有効/無効にするフラグ
@export var enabled: bool = true:
	set(value):
		enabled = value
		if is_inside_tree():
			_update_visibility()

## 画面解像度に応じてスケールするかどうか
## 例: 16:9 の 1920x1080 を基準にスケール
@export var auto_scale_with_viewport: bool = true

@export var reference_resolution: Vector2 = Vector2(1920, 1080)

# --- Aボタン設定 ----------------------------------------------------------

@export_category("Button A")
## Aボタンが押されたときに送出する Input Action 名
## InputMap で事前に "ui_accept" や "attack" などを定義しておきましょう。
@export var action_a: StringName = &"ui_accept"

## Aボタンの表示位置(ビューポート基準)
## 右下寄りに配置するのが一般的ですね。
@export var a_position: Vector2 = Vector2(1600, 800)

## Aボタンのサイズ(テクスチャがない場合の円形ボタン半径程度のイメージ)
@export var a_radius: float = 80.0

## Aボタンのテクスチャ(未設定ならシンプルな円を描画)
@export var a_texture: Texture2D

## Aボタンのラベル(例: "A")
@export var a_label_text: String = "A"

# --- Bボタン設定 ----------------------------------------------------------

@export_category("Button B")
@export var action_b: StringName = &"ui_cancel"
@export var b_position: Vector2 = Vector2(1400, 650)
@export var b_radius: float = 70.0
@export var b_texture: Texture2D
@export var b_label_text: String = "B"

# --- 見た目の共通設定 ----------------------------------------------------

@export_category("Visual")
## ボタンの不透明度
@export_range(0.0, 1.0, 0.05)
@export var button_alpha: float = 0.7

## ボタンが押されているときのスケール倍率
@export_range(0.5, 2.0, 0.05)
@export var pressed_scale: float = 1.1

## テクスチャがないときに描画する円の色
@export var default_color_a: Color = Color(0.2, 0.8, 0.2, 0.7)
@export var default_color_b: Color = Color(0.8, 0.2, 0.2, 0.7)

## ラベルのフォントサイズ
@export var label_font_size: int = 32

# -------------------------------------------------------------------------
# 内部用ノード
# -------------------------------------------------------------------------

var _button_a: TouchScreenButton
var _button_b: TouchScreenButton
var _a_label: Label
var _b_label: Label

func _ready() -> void:
	# Viewportに追従させたいので CanvasLayer を使う
	layer = 10  # UIが他より前面に来るように適当なレイヤーを指定

	# A/Bボタンを生成
	_create_button_a()
	_create_button_b()

	# 初期状態の反映
	_update_visibility()

	# ビューポートサイズ変更に対応
	get_viewport().size_changed.connect(_on_viewport_size_changed)

func _on_viewport_size_changed() -> void:
	if auto_scale_with_viewport:
		_apply_auto_scale()

# -------------------------------------------------------------------------
# Aボタン生成
# -------------------------------------------------------------------------

func _create_button_a() -> void:
	_button_a = TouchScreenButton.new()
	_button_a.name = "ButtonA"
	add_child(_button_a)

	# 親が CanvasLayer の場合は、位置はビューポート座標で解釈されます
	_button_a.position = a_position

	# シンプルな円形の CollisionShape を使う
	var shape := CircleShape2D.new()
	shape.radius = a_radius
	var collider := CollisionShape2D.new()
	collider.shape = shape
	_button_a.add_child(collider)

	# テクスチャ設定(あれば使う)
	if a_texture:
		_button_a.texture = a_texture

	# アクション名を設定
	_button_a.action = action_a

	# ラベル
	_a_label = Label.new()
	_a_label.text = a_label_text
	_a_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_a_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	_a_label.autowrap_mode = TextServer.AUTOWRAP_OFF
	_a_label.position = Vector2.ZERO
	_a_label.size = Vector2(a_radius * 2.0, a_radius * 2.0)
	_button_a.add_child(_a_label)

	# 押下時の見た目変化を処理するため、pressed/ released シグナルを利用
	_button_a.pressed.connect(func():
		_on_button_pressed(_button_a)
	)
	_button_a.released.connect(func():
		_on_button_released(_button_a)
	)

func _create_button_b() -> void:
	_button_b = TouchScreenButton.new()
	_button_b.name = "ButtonB"
	add_child(_button_b)

	_button_b.position = b_position

	var shape := CircleShape2D.new()
	shape.radius = b_radius
	var collider := CollisionShape2D.new()
	collider.shape = shape
	_button_b.add_child(collider)

	if b_texture:
		_button_b.texture = b_texture

	_button_b.action = action_b

	_b_label = Label.new()
	_b_label.text = b_label_text
	_b_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_b_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	_b_label.autowrap_mode = TextServer.AUTOWRAP_OFF
	_b_label.position = Vector2.ZERO
	_b_label.size = Vector2(b_radius * 2.0, b_radius * 2.0)
	_button_b.add_child(_b_label)

	_button_b.pressed.connect(func():
		_on_button_pressed(_button_b)
	)
	_button_b.released.connect(func():
		_on_button_released(_button_b)
	)

# -------------------------------------------------------------------------
# 見た目の更新
# -------------------------------------------------------------------------

func _process(delta: float) -> void:
	# テクスチャがない場合は自前描画を使うので、毎フレーム再描画要求
	if not a_texture or not b_texture:
		queue_redraw()

func _draw() -> void:
	# CanvasLayer 自体には draw は効かないので、
	# 実際には各 TouchScreenButton に描画させるのが正道ですが、
	# シンプルさ重視でここではテクスチャ未設定時のみ簡易描画します。
	# (より厳密にやるなら、独自の Control を継承して描画しましょう)
	if not a_texture and is_instance_valid(_button_a):
		draw_circle(_button_a.position, a_radius, default_color_a.with_alpha(button_alpha))
	if not b_texture and is_instance_valid(_button_b):
		draw_circle(_button_b.position, b_radius, default_color_b.with_alpha(button_alpha))

func _on_button_pressed(button: TouchScreenButton) -> void:
	# 押されたボタンを少し大きくする
	button.scale = Vector2.ONE * pressed_scale

func _on_button_released(button: TouchScreenButton) -> void:
	button.scale = Vector2.ONE

func _update_visibility() -> void:
	visible = enabled
	if is_instance_valid(_button_a):
		_button_a.visible = enabled
	if is_instance_valid(_button_b):
		_button_b.visible = enabled

# -------------------------------------------------------------------------
# スケーリング関連
# -------------------------------------------------------------------------

func _apply_auto_scale() -> void:
	if not auto_scale_with_viewport:
		return

	var viewport_size := get_viewport().get_visible_rect().size
	if reference_resolution.x == 0 or reference_resolution.y == 0:
		return

	# 単純に X/Y の小さい方に合わせてスケール
	var scale_factor := min(
		viewport_size.x / reference_resolution.x,
		viewport_size.y / reference_resolution.y
	)

	scale = Vector2.ONE * scale_factor

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

## コードから一時的にボタンを隠したいとき用
func set_enabled(value: bool) -> void:
	enabled = value
	_update_visibility()

## A/Bボタンのアクション名を動的に切り替える
## 例: メニュー中は "ui_accept" → "ui_select" に変えたい場合など
func set_actions(new_action_a: StringName, new_action_b: StringName) -> void:
	action_a = new_action_a
	action_b = new_action_b
	if is_instance_valid(_button_a):
		_button_a.action = action_a
	if is_instance_valid(_button_b):
		_button_b.action = action_b

使い方の手順

今回は代表的な例として「2Dアクションゲームのプレイヤー」と「敵AIデバッグ用シーン」での使い方を見ていきましょう。

手順①: スクリプトをプロジェクトに追加

  1. res://components/VirtualButtons.gd など、好きな場所に上記スクリプトを保存します。
  2. Godotエディタで再読み込みすると、ノード追加ダイアログの検索欄VirtualButtons が選べるようになります(class_name のおかげですね)。

手順②: プレイヤーシーンに「合成」して使う

典型的な 2D プレイヤーシーン構成はこんな感じだと思います:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── VirtualButtons (CanvasLayer)
  1. Player.tscn を開き、「+」ボタンから VirtualButtons ノードを追加します。
  2. インスペクタで以下を設定します:
    • action_a → 例: "attack"
    • action_b → 例: "jump"
    • a_position / b_position → 画面右下あたりに好みで配置
    • a_texture / b_texture → 用意したボタン画像があれば設定
  3. Project Settings > Input Mapattackjump を定義し、キーボード/ゲームパッド用の入力も登録しておくと、PCとスマホ両対応になります。

プレイヤー側のスクリプトでは、通常の InputAction として扱うだけでOKです:


extends CharacterBody2D

const SPEED := 200.0
const JUMP_VELOCITY := -400.0

func _physics_process(delta: float) -> void:
	var dir := 0.0
	if Input.is_action_pressed("move_left"):
		dir -= 1.0
	if Input.is_action_pressed("move_right"):
		dir += 1.0

	velocity.x = dir * SPEED

	# Bボタン(jump)を押したとき
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY

	# Aボタン(attack)
	if Input.is_action_just_pressed("attack"):
		_attack()

	move_and_slide()

func _attack() -> void:
	print("Attack!")

ここがポイントで、プレイヤーは「仮想ボタンの存在を知らない」ままInput のアクションだけを見ています。
つまり、「継承で MobilePlayer を作る」とか、「Player シーンに直接 TouchScreenButton を埋め込む」といった結合度の高い設計を避けられます。

手順③: 共通UIとして別シーンに置くパターン

もう一つの使い方として、「UI専用シーンに VirtualButtons を置いて、どのゲームシーンにも読み込む」パターンもおすすめです。

VirtualButtonsUI (CanvasLayer)
 └── VirtualButtons

これを VirtualButtonsUI.tscn として保存しておき、ゲームのメインシーン側で instancing します:

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 ├── Enemies (Node2D)
 └── VirtualButtonsUI (CanvasLayer)
      └── VirtualButtons

この構成なら、

  • 全シーンで同じレイアウト・同じアクション設定の仮想ボタンを共有可能
  • 仮想ボタンのデバッグやレイアウト変更が1ファイルで完結
  • プレイヤーだけでなく、メニューシーンやデバッグシーンでも同じコンポーネントを使い回せる

手順④: 敵AIデバッグ用シーンでの活用例

例えば、敵AIの挙動を確認するデバッグシーンを作るときに、

EnemyDebugScene (Node2D)
 ├── Enemy (CharacterBody2D)
 └── VirtualButtons (CanvasLayer)

という構成にして、

  • Aボタン → 敵の「行動パターン変更」
  • Bボタン → 敵の「リセット」

のようなアクションを割り当てておくと、スマホ実機でのAIデバッグがかなり楽になります。
このときも、敵のスクリプトは InputAction しか見ないので、本番ゲームに余計な依存が入りません。

メリットと応用

この「VirtualButtons」コンポーネントを使うメリットは、ざっくり言うと次の3つです。

  1. シーン構造がスッキリする
    プレイヤーや敵などのロジックノードは、その役割に集中できます。
    仮想ボタンは CanvasLayer として独立しているので、UIとゲームロジックの責務がきれいに分離されます。
  2. 再利用性が高い
    どのシーンにも VirtualButtons をポン付けすれば同じ操作体系を再現できます。
    「タイトル画面」「ステージ選択」「ゲーム本編」「デバッグシーン」など、すべて同じコンポーネントでOKです。
  3. テストと切り替えが楽
    enabled フラグや set_enabled() を使えば、PCビルドでは非表示、Androidビルドでは表示…といった切り替えも簡単です。
    将来的に「仮想スティック」コンポーネントを追加しても、プレイヤー側は InputAction を見るだけなので変更が最小限で済みます。

「継承で MobilePlayer を増やす」のではなく、「Player はそのまま、入力デバイスだけをコンポーネントで差し替える」という発想ですね。
これがまさに「継承より合成(Composition)」の強みです。

改造案: 一時的にボタンのレイアウトを切り替える

例えば「メニュー中はボタンを中央に寄せる」「ゲーム中は右下に戻す」といったレイアウト切り替えをしたくなったら、
こんな感じのヘルパー関数を VirtualButtons に追加しておくと便利です:


func set_layout_for_mode(mode: String) -> void:
	# モードに応じてボタン位置を切り替える簡易サンプル
	match mode:
		"gameplay":
			a_position = Vector2(1600, 800)
			b_position = Vector2(1400, 650)
		"menu":
			a_position = Vector2(960, 700)
			b_position = Vector2(960, 500)
		_:
			return

	# 実際のノード位置に反映
	if is_instance_valid(_button_a):
		_button_a.position = a_position
	if is_instance_valid(_button_b):
		_button_b.position = b_position

メインシーン側からは、


$VirtualButtons.set_layout_for_mode("menu")

のように呼ぶだけで、ゲームの状態に応じて仮想ボタンのレイアウトを柔軟に変えられます。

このように、「VirtualButtons」をひとつのコンポーネントとして育てていくと、
スマホ向けの操作周りを1ファイルに集約できて、プロジェクト全体の見通しがかなり良くなります。
ぜひ自分のゲーム用にカスタマイズしてみてください。