タッチ対応のズームって、Godot標準だけでやろうとするとちょっと面倒ですよね。
例えば:

  • プレイヤー用カメラ、マップ用カメラ、UI用カメラ…シーンごとに毎回ピンチ処理を書く羽目になる
  • InputEventScreenTouchInputEventScreenDrag を直接ハンドリングすると、指が増えた/減った時の状態管理がぐちゃっとしがち
  • 継承で Camera2D を拡張すると、「ピンチ対応カメラ」「シェイク対応カメラ」「制限付きカメラ」…とクラスが分裂して管理地獄になる

こういう「入力 → カメラ操作」のロジックは、本来はカメラ本体とは独立させておきたいところです。
そこで今回は、どのカメラにもポン付けできるピンチズーム専用コンポーネント 「PinchZoom」 を用意して、

  • どのシーンでも同じコンポーネントをアタッチするだけ
  • ズーム範囲や感度は @export でインスペクタから調整
  • カメラ側は「ズームされるだけ」のシンプルな存在に

という「継承より合成」スタイルにしてみましょう。


【Godot 4】指2本でズームイン・アウト!「PinchZoom」コンポーネント

フルコード(GDScript / Godot 4)


extends Node
class_name PinchZoom
## スマホの2本指ピンチで Camera2D の zoom を操作するコンポーネント
##
## 使い方:
## - Camera2D の子、または同じシーン内の任意のノードとして追加
## - `target_camera` をインスペクタで設定(未設定なら親階層から自動で探す)
## - モバイル端末またはエミュレートされたタッチ入力で動作

@export var target_camera: Camera2D:
	set(value):
		target_camera = value
		# カメラを差し替えたときに現在のズーム値を反映
		if is_instance_valid(target_camera):
			_current_zoom = target_camera.zoom

## 最小ズーム倍率(数値が小さいほど「引き」で表示)
@export_range(0.05, 5.0, 0.01)
var min_zoom: float = 0.3

## 最大ズーム倍率(数値が大きいほど「寄り」で表示)
@export_range(0.05, 5.0, 0.01)
var max_zoom: float = 3.0

## ピンチ感度。大きいほど少しの指の移動で大きくズームする
@export_range(0.01, 2.0, 0.01)
var pinch_sensitivity: float = 0.3

## ズーム変更時に補間するかどうか(true なら滑らかにズーム)
@export var smooth_zoom: bool = true

## 補間速度(大きいほど素早く目標ズームに追従)
@export_range(1.0, 30.0, 0.5)
var zoom_lerp_speed: float = 10.0

## ピンチとして認識する最小距離(ピクセル)
## これより小さい距離の 2 本指は「誤タッチ」とみなして無視しやすくなる
@export_range(0.0, 200.0, 5.0)
var min_pinch_distance: float = 20.0

## Input イベントを「消費」するかどうか。
## true にすると、ピンチ中のドラッグイベントが他ノードに伝播しにくくなる。
@export var consume_input: bool = true

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

# 現在アクティブなタッチポイント(finger_index → position)
var _touches: Dictionary = {}

# ピンチ開始時の指間距離
var _initial_pinch_distance: float = 0.0

# ピンチ開始時のカメラ zoom
var _initial_zoom: Vector2 = Vector2.ONE

# 現在の目標ズーム値
var _target_zoom: Vector2 = Vector2.ONE

# 実際の適用ズーム値(補間用)
var _current_zoom: Vector2 = Vector2.ONE

# ピンチ中かどうか
var _is_pinching: bool = false


func _ready() -> void:
	# target_camera が未設定なら親階層から自動で探す
	if target_camera == null:
		target_camera = _find_camera_in_parents()
	
	if target_camera == null:
		push_warning("[PinchZoom] Camera2D が見つかりません。target_camera を設定してください。")
	else:
		_current_zoom = target_camera.zoom
		_target_zoom = target_camera.zoom


func _unhandled_input(event: InputEvent) -> void:
	# タッチイベントのみ処理
	if event is InputEventScreenTouch:
		_handle_screen_touch(event)
	elif event is InputEventScreenDrag:
		_handle_screen_drag(event)


func _process(delta: float) -> void:
	if not is_instance_valid(target_camera):
		return
	
	if smooth_zoom:
		# 補間しながらズームを適用
		_current_zoom = _current_zoom.lerp(_target_zoom, zoom_lerp_speed * delta)
	else:
		_current_zoom = _target_zoom
	
	# 実際のカメラに反映
	target_camera.zoom = _current_zoom


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

func _handle_screen_touch(event: InputEventScreenTouch) -> void:
	if event.pressed:
		# 指が画面に触れた
		_touches[event.index] = event.position
	else:
		# 指が離れた
		_touches.erase(event.index)
		
		# 2本指のどちらかが離れたらピンチ終了
		if _is_pinching and _touches.size() < 2:
			_is_pinching = false
	
	# 2本指になった瞬間にピンチ開始判定
	if _touches.size() == 2 and not _is_pinching:
		var points := _get_first_two_touch_positions()
		_initial_pinch_distance = points[0].distance_to(points[1])
		_initial_zoom = _current_zoom
		_is_pinching = true


func _handle_screen_drag(event: InputEventScreenDrag) -> void:
	# タッチ中の指の位置を更新
	if _touches.has(event.index):
		_touches[event.index] = event.position
	
	# ピンチ中かつ2本指が揃っているときだけズーム計算
	if _is_pinching and _touches.size() == 2:
		var points := _get_first_two_touch_positions()
		var current_distance := points[0].distance_to(points[1])
		
		# 距離が小さすぎる場合はノイズ扱い
		if _initial_pinch_distance <= 0.0:
			return
		
		if current_distance < min_pinch_distance:
			return
		
		# 距離の比率からスケールを算出
		var ratio := current_distance / _initial_pinch_distance
		
		# 感度を適用(1.0 だと距離比率そのまま)
		var scale := pow(ratio, pinch_sensitivity)
		
		# 新しいズーム値を計算
		var new_zoom := _initial_zoom / scale
		
		# 最小・最大ズームでクランプ
		new_zoom.x = clampf(new_zoom.x, min_zoom, max_zoom)
		new_zoom.y = clampf(new_zoom.y, min_zoom, max_zoom)
		
		_target_zoom = new_zoom
		
		if consume_input:
			# このイベントを他ノードに伝播させない
			get_viewport().set_input_as_handled()


# --- ユーティリティ ---------------------------------------------------

## 親階層から最寄りの Camera2D を探す
func _find_camera_in_parents() -> Camera2D:
	var current: Node = get_parent()
	while current:
		if current is Camera2D:
			return current
		current = current.get_parent()
	return null


## Dictionary から最初の2つのタッチ位置を Vector2 配列で返す
func _get_first_two_touch_positions() -> Array:
	var positions: Array = []
	for key in _touches.keys():
		positions.append(_touches[key])
		if positions.size() == 2:
			break
	return positions

使い方の手順

ここでは代表的な3パターンで使い方を説明します。

  1. プレイヤー追従カメラにピンチズームを付ける
  2. マップ全体を見るための俯瞰カメラに付ける
  3. UI内のミニマップやキャンバスに付ける

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

  1. 上の PinchZoom.gd コードをそのままコピー
  2. res://components/PinchZoom.gd など、分かりやすい場所に保存
  3. Godot を再読み込みすると、ノード追加ダイアログのスクリプト一覧やインスペクタの ScriptPinchZoom クラスが出てくるようになります

手順②:プレイヤー用カメラにアタッチする例

例えば、こんな 2D プレイヤーシーンがあるとします:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D
      └── PinchZoom (Node)
  1. Player シーンを開き、Camera2D を選択
  2. Camera2D の子として Node を追加
  3. その NodePinchZoom.gd をアタッチ(または Node 追加時にスクリプト付きノードとして PinchZoom を選択)
  4. target_camera は未設定でも OK(親階層から自動で Camera2D を見つけます)

これで、スマホ実機やエミュレートされたタッチ入力で、プレイヤーを追いかけるカメラをピンチでズームイン・アウトできるようになります。

手順③:マップ全体カメラに付ける例

マップを俯瞰する専用カメラシーンを作るときも、同じコンポーネントを再利用できます。

World (Node2D)
 ├── TileMap
 ├── Player (CharacterBody2D)
 └── MapCamera (Camera2D)
      └── PinchZoom (Node)
  • MapCamera を選択し、子ノードとして PinchZoom を追加
  • インスペクタで min_zoom / max_zoom をマップ用に調整(例:min_zoom = 0.2, max_zoom = 4.0
  • プレイヤー用カメラとマップ用カメラで、別々のズームレンジを持たせることができます

手順④:UI内のミニマップやキャンバスに付ける例

UI の中に「描画キャンバス」や「ミニマップ」があるケースも、同じコンポーネントで対応できます。

MapView (Control)
 ├── SubViewportContainer
 │    └── SubViewport
 │         └── MapRoot (Node2D)
 │              ├── TileMap
 │              └── MapCamera (Camera2D)
 │                   └── PinchZoom (Node)
 └── Frame (TextureRect)
  • SubViewport 内の MapCameraPinchZoom を付けるだけ
  • UI 側は特にタッチ処理を書かなくても、SubViewport 上でピンチ操作がそのままカメラに届きます
  • このときも target_camera は自動検出に任せて OK です

「プレイヤー」「マップ」「ミニマップ」など、用途の違うカメラでも、同じコンポーネントをアタッチするだけで振る舞いを共有できるのがポイントですね。


メリットと応用

コンポーネントとしての PinchZoom を使うメリットを整理すると:

  • カメラの継承地獄から解放
    PinchCamera2D, ShakeCamera2D, LimitCamera2D…とクラスを増やす代わりに、
    Camera2D + PinchZoom + CameraShake + CameraLimit のように「合成」で機能を足していけます。
  • シーン構造がフラットで読みやすい
    「カメラの子にズームコンポーネントが付いている」という構造が一目で分かるので、
    大規模プロジェクトでも「どこでズームを制御しているか」が迷子になりません。
  • パラメータをシーンごとに簡単調整
    min_zoom / max_zoom / pinch_sensitivity をインスペクタから変えるだけで、
    プレイヤー用カメラは「寄りやすく」、マップカメラは「引きやすく」など、シーンごとに気軽にチューニングできます。
  • 入力ロジックの再利用性アップ
    タッチイベントの面倒な管理(指の本数、距離、誤タッチ除去など)を一箇所に閉じ込められるので、
    今後タッチ周りの仕様を変えたくなっても、このコンポーネントだけ触れば済みます。

ちょい改造案:ダブルタップでズームリセット

「ピンチでズームしたあと、ダブルタップで元の倍率に戻したい」という要望はよくあるので、
簡単な改造として PinchZoom にダブルタップ検出を追加する例を載せておきます。

以下の func _handle_screen_touch を、元のものと差し替えるだけで動きます:


# 追加の設定パラメータ
@export_range(0.05, 1.0, 0.05)
var double_tap_time: float = 0.3  # ダブルタップ判定時間(秒)

var _last_tap_time: float = 0.0
var _last_tap_pos: Vector2 = Vector2.ZERO
var _double_tap_distance: float = 50.0  # 位置がこの距離以内なら同じ場所とみなす


func _handle_screen_touch(event: InputEventScreenTouch) -> void:
	if event.pressed:
		# ダブルタップ判定(1本指のみ対象)
		if _touches.size() == 0:
			var now := Time.get_ticks_msec() / 1000.0
			var dt := now - _last_tap_time
			if dt <= double_tap_time and event.position.distance_to(_last_tap_pos) <= _double_tap_distance:
				# ダブルタップと判定 → ズームリセット
				_target_zoom = Vector2.ONE
				_initial_zoom = _target_zoom
				_current_zoom = _target_zoom
			_last_tap_time = now
			_last_tap_pos = event.position
		
		# 通常のタッチ管理
		_touches[event.index] = event.position
	else:
		_touches.erase(event.index)
		if _is_pinching and _touches.size() < 2:
			_is_pinching = false
	
	if _touches.size() == 2 and not _is_pinching:
		var points := _get_first_two_touch_positions()
		_initial_pinch_distance = points[0].distance_to(points[1])
		_initial_zoom = _current_zoom
		_is_pinching = true

このように、「ピンチズーム」「ダブルタップリセット」「カメラシェイク」などを全部別コンポーネントとして切り出していくと、
カメラ周りのロジックを好きなように組み合わせられるようになります。
継承ベースで頑張っていた頃よりも、だいぶ気楽に機能追加できるようになりますよ。