【Godot 4】VirtualJoystick (仮想スティック) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

2Dゲームをスマホ対応しようとすると、最初につまずきやすいのが「仮想スティック」まわりですよね。
Godot標準だと InputEventScreenTouchInputEventScreenDrag を直接処理したり、UIノードを継承して専用の VirtualJoystickControl みたいなクラスを作ってしまいがちです。

でも、そうすると:

  • プレイヤーシーンにだけベタ書きされたタッチ処理で、他のキャラに流用しづらい
  • UIシーンの中に深いノード階層ができて、「どこをいじればいいのか」分かりづらい
  • 「スティックの見た目」と「入力ロジック」が密結合になり、デザイン変更でコードも壊れる

そこで今回は、「仮想スティック」をひとつの独立コンポーネントとして切り出してしまいましょう。
シーンどこにでもポンと置けて、プレイヤーにも敵AIデバッグ用にも、動く床テスト用にも流用できるようにします。

【Godot 4】タッチ操作をコンポーネント化!「VirtualJoystick」コンポーネント

この VirtualJoystick は:

  • 画面をタップした位置にスティックの「ベース」と「ノブ」を表示
  • ドラッグ方向・強さを正規化ベクトル(Vector2)として公開
  • スティックの半径・デッドゾーン・表示/非表示の挙動を @export で柔軟に設定
  • 「見た目」と「入力ロジック」を分離しやすい構成

として設計しています。
UIツリーの中にひとつ置いておけば、プレイヤー側は「ただ joystick_vector を読むだけ」でOK、という立ち位置ですね。


フルコード: VirtualJoystick.gd


extends Control
class_name VirtualJoystick
"""
タッチパネル用の仮想スティックコンポーネント。

・画面タップ位置にスティックを表示
・ドラッグ方向を -1.0〜1.0 の Vector2 として公開
・UIの任意の場所に置いて使えるように Control を継承
"""

## === エクスポートパラメータ ===

@export_group("Joystick Settings")

## スティックの最大半径(ピクセル)
## この距離以上ドラッグしても、ノブ位置はこれ以上外に出ない
@export var radius: float = 120.0

## デッドゾーン(ピクセル)
## この距離以内の微小な入力は 0 とみなす
@export var dead_zone: float = 10.0

## 1本指のみ対応するかどうか
## true の場合、最初に掴んだタッチID以外は無視
@export var single_touch_only: bool = true

## タッチしていないときはスティックを隠すかどうか
@export var hide_when_idle: bool = true

## 画面のどの範囲で有効にするか(null の場合は画面全体)
## 例: 左半分だけ有効にしたい場合は、_ready() で rect として設定してもOK
@export var active_area: Rect2 = Rect2(Vector2.ZERO, Vector2.ZERO)
@export var use_active_area: bool = false


@export_group("Nodes (Optional)")

## ベース画像用のノード(任意)
## 指を置いた位置にこのノードを移動して表示する
@export var base_node_path: NodePath

## ノブ(スティックの先端)画像用ノード(任意)
## ベースを中心とした相対座標で動かす
@export var knob_node_path: NodePath


## === 公開プロパティ(他ノードから読む用) ===

## -1.0〜1.0 の入力ベクトル(ローカル座標系)
## x: 右が +1, 左が -1
## y: 下が +1, 上が -1 (Godotの画面座標系に合わせる)
var joystick_vector: Vector2 = Vector2.ZERO : get = get_joystick_vector

## 現在スティックがアクティブ(タッチ中)かどうか
var is_active: bool = false : get = is_joystick_active


## === 内部用変数 ===

var _touch_id: int = -1
var _start_position: Vector2 = Vector2.ZERO
var _current_position: Vector2 = Vector2.ZERO

var _base_node: Control
var _knob_node: Control


func _ready() -> void:
	# ベース・ノブノードを取得(指定されていなければ null のまま)
	if base_node_path != NodePath():
		_base_node = get_node_or_null(base_node_path)
	if knob_node_path != NodePath():
		_knob_node = get_node_or_null(knob_node_path)

	# hide_when_idle が true の場合は、最初は隠しておく
	if hide_when_idle:
		_set_nodes_visible(false)

	# マウス入力をタッチとして扱う(エディタ上でテストしやすくするため)
	# 実機ではオフにしてもOK
	Input.set_use_accumulated_input(false)


func get_joystick_vector() -> Vector2:
	return joystick_vector


func is_joystick_active() -> bool:
	return is_active


func _gui_input(event: InputEvent) -> void:
	# Control 上での入力を拾う
	# ここではマウスもタッチと同様に扱う

	if event is InputEventScreenTouch:
		_handle_screen_touch(event)
	elif event is InputEventScreenDrag:
		_handle_screen_drag(event)
	elif event is InputEventMouseButton:
		_handle_mouse_button(event)
	elif event is InputEventMouseMotion:
		_handle_mouse_motion(event)


func _handle_screen_touch(event: InputEventScreenTouch) -> void:
	if event.pressed:
		# タッチ開始
		if single_touch_only and _touch_id != -1:
			# すでに他の指で使用中
			return

		if use_active_area and not _is_in_active_area(event.position):
			return

		_start_joystick(event.position, event.index)
	else:
		# タッチ終了
		if event.index == _touch_id:
			_end_joystick()


func _handle_screen_drag(event: InputEventScreenDrag) -> void:
	if event.index != _touch_id:
		return

	_update_joystick(event.position)


func _handle_mouse_button(event: InputEventMouseButton) -> void:
	# マウス左ボタンをタッチの代わりとする
	if event.button_index != MOUSE_BUTTON_LEFT:
		return

	if event.pressed:
		if single_touch_only and _touch_id != -1:
			return

		if use_active_area and not _is_in_active_area(event.position):
			return

		_start_joystick(event.position, 0)
	else:
		if _touch_id == 0:
			_end_joystick()


func _handle_mouse_motion(event: InputEventMouseMotion) -> void:
	if _touch_id != 0:
		return

	if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
		_update_joystick(event.position)
	else:
		# ボタンが離されたのに _touch_id がリセットされていない場合の保険
		if is_active:
			_end_joystick()


func _start_joystick(pos: Vector2, touch_id: int) -> void:
	is_active = true
	_touch_id = touch_id
	_start_position = pos
	_current_position = pos

	# ベースノードをタッチ位置に移動
	if _base_node:
		_base_node.global_position = pos
	# ノブはベースの中心からスタート
	if _knob_node:
		_knob_node.position = Vector2.ZERO

	# 表示制御
	if hide_when_idle:
		_set_nodes_visible(true)

	# ベクトルをリセット
	joystick_vector = Vector2.ZERO


func _update_joystick(pos: Vector2) -> void:
	_current_position = pos
	var delta: Vector2 = _current_position - _start_position
	var distance: float = delta.length()

	# デッドゾーン処理
	if distance <= dead_zone:
		joystick_vector = Vector2.ZERO
		if _knob_node:
			_knob_node.position = Vector2.ZERO
		return

	# 半径を超えないようにクランプ
	var clamped_distance := min(distance, radius)
	var direction: Vector2 = delta.normalized()

	# ノブの見た目の位置(ベースからの相対座標)
	if _knob_node:
		_knob_node.position = direction * clamped_distance

	# 実際の入力ベクトルは 0〜1 に正規化(デッドゾーンを除外)
	var normalized_strength := (clamped_distance - dead_zone) / max(radius - dead_zone, 0.001)
	normalized_strength = clampf(normalized_strength, 0.0, 1.0)
	joystick_vector = direction * normalized_strength


func _end_joystick() -> void:
	is_active = false
	_touch_id = -1
	joystick_vector = Vector2.ZERO

	# ノブ位置をリセット
	if _knob_node:
		_knob_node.position = Vector2.ZERO

	# 非表示にする設定なら隠す
	if hide_when_idle:
		_set_nodes_visible(false)


func _set_nodes_visible(visible: bool) -> void:
	if _base_node:
		_base_node.visible = visible
	if _knob_node:
		_knob_node.visible = visible


func _is_in_active_area(pos: Vector2) -> bool:
	if not use_active_area:
		return true

	# active_area が (0,0,0,0) の場合は画面全体扱いにしてもよいが、
	# ここでは単純に contains() に任せる
	return active_area.has_point(pos)

使い方の手順

ここでは 2Dアクションゲームのプレイヤーを、左下に表示した仮想スティックで動かす例で説明します。

手順①: UIシーンに VirtualJoystick を置く

  1. 上記コードを VirtualJoystick.gd として保存します。
  2. 新規シーンを作成し、ルートに CanvasLayer を置きます(UI用)。
  3. CanvasLayer の子に Control を追加し、名前を VirtualJoystick に変更。
  4. その Control に、今作った VirtualJoystick.gd をアタッチします。
  5. 見た目用に、さらに子として TextureRect を2つ追加し、ベースとノブの画像を設定します。

シーン構成図の例:

UI (CanvasLayer)
 └── VirtualJoystick (Control)  <-- このノードに VirtualJoystick.gd をアタッチ
      ├── Base (TextureRect)
      └── Knob (TextureRect)

VirtualJoystick ノードのインスペクタで:

  • radius: 120 〜 160 くらい(ゲームに応じて調整)
  • dead_zone: 8 〜 16 くらい
  • hide_when_idle: ON(タッチしていない時は非表示)
  • base_node_path: Base
  • knob_node_path: Knob
  • use_active_area: ON

さらに、左半分だけ有効にしたい場合は、VirtualJoystick にこんな感じのスクリプトを1行足してもOKです:


func _ready() -> void:
	active_area = Rect2(Vector2.ZERO, get_viewport_rect().size * Vector2(0.5, 1.0))

手順②: プレイヤーシーンに「入力を読むだけ」のコンポーネント化

プレイヤーシーンは、できるだけ「移動ロジック」と「入力ソース」を分離したいので、
プレイヤー自身は Vector2 の入力を受け取るだけにしましょう。

プレイヤーシーン構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PlayerMover (Node)  <-- 入力ベクトルを受け取って動くだけのコンポーネント

PlayerMover.gd の例:


extends Node

@export var speed: float = 200.0
@export var body_path: NodePath  # CharacterBody2D へのパス
@export var joystick_path: NodePath  # VirtualJoystick へのパス(UI側)

var _body: CharacterBody2D
var _joystick: VirtualJoystick


func _ready() -> void:
	_body = get_node_or_null(body_path)
	_joystick = get_node_or_null(joystick_path)


func _physics_process(delta: float) -> void:
	if _body == null:
		return

	var input_vec := Vector2.ZERO

	# キーボード入力(PC用)も併用したい場合
	input_vec.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
	input_vec.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")

	# 仮想スティック入力(モバイル用)をマージ
	if _joystick and _joystick.is_joystick_active():
		# スティック入力を優先させるなら、単に代入でもOK
		input_vec = _joystick.joystick_vector

	if input_vec.length_squared() > 1.0:
		input_vec = input_vec.normalized()

	_body.velocity = input_vec * speed
	_body.move_and_slide()

このようにしておけば、プレイヤーは「入力ベクトルをどう用意するか」を気にしないで済みます。
PCビルドならキーボード、スマホビルドなら仮想スティック、という切り替えも簡単ですね。

手順③: メインシーンで Player と UI を合体

メインシーンの構成例:

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PlayerMover (Node)
 └── UI (CanvasLayer)
      └── VirtualJoystick (Control)
           ├── Base (TextureRect)
           └── Knob (TextureRect)

PlayerMoverbody_path には ../(親の Player)、
joystick_path には ../../UI/VirtualJoystick を設定しておきましょう。

手順④: 他の用途への応用例

  • 敵AIデバッグ用カメラ: カメラを VirtualJoystick で手動操作して、ステージ全体を確認
  • 動く床のテスト: デザイナーが仮想スティックで床を動かして挙動を確認
  • UIカーソル移動: キーボードの代わりに、仮想スティックでメニューカーソルを動かす

どのケースでも、「ノードにコンポーネントを1個アタッチして、ベクトルを読むだけ」で済むのがポイントですね。


メリットと応用

この VirtualJoystick コンポーネントを使うことで、コンポジション志向の恩恵がかなり大きくなります:

  • シーン構造がスッキリ:プレイヤーや敵のシーンにタッチ処理を埋め込まず、UI側に1つ置くだけ。
  • 再利用性が高い:別ゲームでも、そのまま VirtualJoystick.tscn をコピペして使い回し可能。
  • 見た目とロジックの分離:ベース/ノブの画像を変えても、スクリプトはそのまま。
  • テストしやすい:PC上ではマウスで動かせるので、実機がなくてもある程度動作確認できる。

さらに、ちょっとした改造で「よりゲームに特化した挙動」にすることも簡単です。

改造案: 入力方向を「8方向」にスナップする

アクションゲームなどで、斜め入力を禁止したい場合は、_update_joystick() の最後でベクトルをスナップしてしまう手もあります。


func _snap_to_8_directions(vec: Vector2) -> Vector2:
	if vec == Vector2.ZERO:
		return vec

	# 8方向の基準ベクトル
	var directions := [
		Vector2.RIGHT,
		Vector2(1, 1).normalized(),
		Vector2.DOWN,
		Vector2(-1, 1).normalized(),
		Vector2.LEFT,
		Vector2(-1, -1).normalized(),
		Vector2.UP,
		Vector2(1, -1).normalized(),
	]

	var best_dir := directions[0]
	var best_dot := -INF

	for d in directions:
		var dot := vec.normalized().dot(d)
		if dot > best_dot:
			best_dot = dot
			best_dir = d

	return best_dir * vec.length()

これを _update_joystick() の最後で:


	# 正規化後
	joystick_vector = direction * normalized_strength

	# 8方向にスナップしたい場合はここで変換
	# joystick_vector = _snap_to_8_directions(joystick_vector)

のように差し替えれば、8方向限定の仮想スティックになります。
こうした「ゲーム固有のルール」は、コンポーネント側でカスタマイズしてもいいですし、
別クラスに分けて「入力フィルタ」として差し込む設計にしても面白いですね。

継承ベースでガチガチに固めるのではなく、「入力を出す箱」としてのコンポーネントを用意しておくと、
あとから仕様が変わっても柔軟に対応できるので、ぜひ自分用の VirtualJoystick を育てていきましょう。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!