2Dゲームをスマホ対応しようとすると、最初につまずきやすいのが「仮想スティック」まわりですよね。
Godot標準だと InputEventScreenTouch や InputEventScreenDrag を直接処理したり、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 を置く
- 上記コードを
VirtualJoystick.gdとして保存します。 - 新規シーンを作成し、ルートに
CanvasLayerを置きます(UI用)。 CanvasLayerの子にControlを追加し、名前をVirtualJoystickに変更。- その
Controlに、今作ったVirtualJoystick.gdをアタッチします。 - 見た目用に、さらに子として
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:Baseknob_node_path:Knobuse_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)
PlayerMover の body_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 を育てていきましょう。




