Godot 4で「クリックできるオブジェクト」を作るとき、Area2Dならinput_eventを毎回書いたり、Controlならgui_inputをオーバーライドしたり…と、実装パターンがバラバラになりがちですよね。
さらに、プレイヤー、ボタン、オブジェクトなどそれぞれのスクリプト内にクリック処理を直書きすると、継承ツリーもスクリプトもどんどん肥大化していきます。

そこでこの記事では、「親ノードがクリックされたらシグナルだけ飛ばしてくれる」超シンプルなコンポーネント ClickableObject を用意して、どんなノードにも後付けでクリック機能を合成できるようにしてみましょう。
親が Area2D でも Control でも、同じインターフェースで「クリックされた」を受け取れるようにしておくと、シーン構造もスッキリしてレベルデザインがかなり楽になります。

【Godot 4】なんでもクリック可能に!「ClickableObject」コンポーネント

コンポーネントのねらい

  • 親が Area2D または Control のどちらでも動く。
  • クリック処理はすべてコンポーネント側に集約し、ゲームロジックとは分離。
  • clicked シグナルを発行するだけの「薄い」コンポーネントにして、再利用しやすく。

つまり、「クリック判定」と「クリックされたあとの挙動」を完全に分離して、継承ではなくコンポーネントの合成で機能を組み立てていくスタイルですね。


フルコード: ClickableObject.gd


extends Node
class_name ClickableObject
## 親ノード(Area2D / Control など)がクリックされたことを検知し、
## 共通の clicked シグナルを発行するコンポーネント。
##
## - 親が Area2D の場合: input_event シグナルを利用
## - 親が Control の場合: gui_input シグナルを利用
##
## 親ノード側には特別なコードは不要で、このコンポーネントを
## 子としてアタッチするだけでクリック検知が可能になります。

## クリックされたときに発行されるシグナル
## 引数:
##   - event: 入力イベント (InputEventMouseButton など)
##   - position: ローカル座標 (Control) またはワールド座標 (Area2D)
signal clicked(event, position)

## クリックとして扱うマウスボタン
## - BUTTON_LEFT / BUTTON_RIGHT / BUTTON_MIDDLE など
@export var mouse_button: MouseButton = MOUSE_BUTTON_LEFT

## クリック判定にホバー(マウスオーバー)を必須にするかどうか
## true の場合:
##   - Area2D: shape 内にカーソルがあるときのみ判定
##   - Control: コントロールの矩形内でクリックされたときのみ判定
@export var require_hover: bool = true

## UI (Control) 用のイベントも拾うかどうか
## true にすると、親が Control の場合に gui_input からも拾う
@export var listen_control: bool = true

## 2D (Area2D) 用のイベントも拾うかどうか
## true にすると、親が Area2D の場合に input_event からも拾う
@export var listen_area2d: bool = true

## デバッグ用: クリックを検知したらコンソールにログを出す
@export var debug_print: bool = false

## 内部状態: 親がどのタイプかをキャッシュしておく
var _parent_area2d: Area2D = null
var _parent_control: Control = null


func _ready() -> void:
	## 親ノードを取得して、型ごとに初期化
	var parent := get_parent()
	if parent is Area2D and listen_area2d:
		_parent_area2d = parent
		_setup_area2d(parent)
	elif parent is Control and listen_control:
		_parent_control = parent
		_setup_control(parent)
	else:
		push_warning(
			"ClickableObject: 親が Area2D または Control ではないか、" +
			"listen_* の設定が無効です。クリックを検知できません。"
		)


func _setup_area2d(area: Area2D) -> void:
	## Area2D の input_pickable を有効化しておくとクリックを拾いやすい
	if not area.input_pickable:
		area.input_pickable = true

	## Area2D の input_event シグナルに接続
	if not area.is_connected("input_event", Callable(self, "_on_area_input_event")):
		area.input_event.connect(_on_area_input_event)


func _setup_control(control: Control) -> void:
	## Control のマウスフィルタが IGNORE だとイベントを受け取れないので注意
	if control.mouse_filter == Control.MOUSE_FILTER_IGNORE:
		push_warning("ClickableObject: 親 Control の mouse_filter が IGNORE です。クリックを拾えない可能性があります。")

	## Control の gui_input シグナルに接続
	if not control.is_connected("gui_input", Callable(self, "_on_control_gui_input")):
		control.gui_input.connect(_on_control_gui_input)


func _on_area_input_event(
	viewport: Node,
	event: InputEvent,
	shape_idx: int
) -> void:
	## Area2D から渡される input_event をフィルタリングしてクリックだけ拾う
	if not (event is InputEventMouseButton):
		return

	var mb := event as InputEventMouseButton

	## 指定したボタンが押された瞬間だけをクリックとして扱う
	if mb.button_index != mouse_button:
		return
	if not mb.pressed:
		return

	## require_hover が true の場合、Area2D の shape 上でのクリックのみを許可
	if require_hover:
		## Area2D の input_event はすでに「ヒットしたイベント」なので、
		## ここでは追加のホバー判定は不要とみなす。
		## どうしても厳密にやりたい場合は、world_2d.direct_space_state を使って
		## RayCast 等で判定してもよいです。

	var click_position: Vector2 = mb.position  ## ワールド座標
	_emit_clicked(event, click_position)


func _on_control_gui_input(event: InputEvent) -> void:
	## Control から渡される gui_input をフィルタリング
	if not (event is InputEventMouseButton):
		return

	var mb := event as InputEventMouseButton

	if mb.button_index != mouse_button:
		return
	if not mb.pressed:
		return

	## Control の場合、gui_input は既にその Control 上で起きたイベントなので
	## require_hover が true でも特に追加判定は不要とみなします。
	## (クリックが外側ならそもそも gui_input が飛ばない想定)

	var click_position: Vector2 = mb.position  ## ビューポート座標 or ローカル座標
	_emit_clicked(event, click_position)


func _emit_clicked(event: InputEvent, position: Vector2) -> void:
	if debug_print:
		print("ClickableObject: clicked at ", position, " on ", get_parent())

	## 共通シグナルを発行
	clicked.emit(event, position)

使い方の手順

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

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

  1. 上記の ClickableObject.gd をプロジェクト内の適当な場所(例: res://components/ClickableObject.gd)に保存します。
  2. Godot エディタを再読み込みすると、ノード追加ダイアログで「ClickableObject」クラスとして選択可能になります。

手順②: Area2D にクリック機能をつける例(例: 宝箱)

例えば 2D の宝箱オブジェクトをクリックして開けたい場合:

TreasureChest (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ClickableObject (Node)
  1. TreasureChest シーンのルートを Area2D にして、Sprite2DCollisionShape2D を usual 通り設定。
  2. 子ノードとして ClickableObject を追加します。
  3. TreasureChest のスクリプト側で ClickableObject.clicked シグナルを受け取り、宝箱を開く処理を書きます。

例: TreasureChest.gd


extends Area2D

@onready var clickable: ClickableObject = $ClickableObject

var is_opened: bool = false

func _ready() -> void:
	## コンポーネントの clicked シグナルに接続
	clickable.clicked.connect(_on_clicked)


func _on_clicked(event: InputEvent, position: Vector2) -> void:
	if is_opened:
		return

	is_opened = true
	print("宝箱が開きました! クリック位置:", position)
	## ここでアニメーション再生やアイテム生成などを行う

このように、宝箱本体は「開くロジック」だけを持ち、クリック判定は ClickableObject に丸投げできます。

手順③: Control(UI ボタン)にクリック機能をつける例

今度は UI 側で使ってみます。例えば、画像をクリックするとポップアップを開くようなシンプルな UI:

PopupButton (Control)
 ├── TextureRect
 └── ClickableObject (Node)
  1. PopupButton シーンのルートを Control にします。
  2. 子に TextureRect(アイコン画像など)を置きます。
  3. 同じ階層に ClickableObject を追加します。
  4. PopupButton.gd で clicked シグナルを受け取ります。

例: PopupButton.gd


extends Control

@onready var clickable: ClickableObject = $ClickableObject

func _ready() -> void:
	clickable.clicked.connect(_on_clicked)


func _on_clicked(event: InputEvent, position: Vector2) -> void:
	print("UI ボタンがクリックされました。位置:", position)
	## ここでポップアップを開く処理などを書く

UI 側でも 2D 側でも、同じ clicked(event, position) シグナルで扱えるのがポイントです。

手順④: プレイヤー・敵・ギミックなどへ量産的に付けていく

例えば、クリックで話しかけられる NPC を作る場合:

NPC (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ClickableObject (Node)

または、クリックすると動き出すギミック:

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ClickableObject (Node)

親が Area2D / Control であれば、そのまま動きますし、CharacterBody2DNode2D をルートにしたい場合は、ルートの下に Area2D を挟んでそこに ClickableObject をぶら下げる構造にするとよいです。

NPC (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ClickArea (Area2D)
      ├── CollisionShape2D
      └── ClickableObject (Node)

メリットと応用

  • スクリプトの責務分離
    クリック判定は ClickableObject に集約し、宝箱・NPC・ボタンなどは「クリックされた後の振る舞い」だけを書けばよくなります。
    結果として、各シーンのスクリプトがシンプルになり、保守性が上がります。
  • ノード構造がフラットで見通しが良い
    「クリック可能なもの専用のベースクラス」を作って継承で増やしていくと、継承ツリーがどんどん複雑になります。
    一方、このコンポーネント方式なら ClickableObject を子に 1 個付けるだけなので、どのノードがクリック可能かが一目でわかるのも利点ですね。
  • UI とゲームオブジェクトで共通のインターフェース
    2D オブジェクト(Area2D)も UI(Control)も、clicked(event, position) で統一できるので、「クリックで何か起こす」処理を同じような書き方で量産できます。
  • テストや差し替えがしやすい
    クリック判定の仕様を変えたい場合(例えばダブルクリックにしたい、右クリックも対応したいなど)、ClickableObject 1 箇所を改造するだけで、全オブジェクトの挙動を一括で変えられます。

改造案: ダブルクリック対応を追加してみる

例えば、「短時間に 2 回クリックされたら double_clicked シグナルを出す」ように拡張したい場合、こんな感じで関数を追加できます。


signal double_clicked(event, position)

@export var double_click_threshold: float = 0.25  ## 2 回のクリックがこの秒数以内ならダブルクリックとみなす

var _last_click_time: float = -1.0


func _emit_clicked(event: InputEvent, position: Vector2) -> void:
	var now := Time.get_ticks_msec() / 1000.0

	if debug_print:
		print("ClickableObject: clicked at ", position, " on ", get_parent())

	## ダブルクリック判定
	if _last_click_time >= 0.0 and now - _last_click_time <= double_click_threshold:
		double_clicked.emit(event, position)
		_last_click_time = -1.0  ## リセット
	else:
		_last_click_time = now

	clicked.emit(event, position)

こうしておけば、将来的に「一部のオブジェクトはダブルクリックでだけ動く」みたいな仕様にも簡単に対応できます。
クリック判定のロジックをコンポーネントに閉じ込めておくと、ゲーム全体の操作感を後から変えやすいのでおすすめです。