【Godot】Godotのタッチ入力入門:指2本で拡大し、ノッチを避け、縦横どちらでも崩さない

作成: 2026-09-22最終更新: 2026-10-10

スマホのタッチ入力で2本指の拡大を作り、HUDをカメラの穴を避けた画面の内側へ収める方法を解説します。

PCで作ったゲームをスマホでも遊べるようにするときに押さえるのは、指での操作の受け取り方と、カメラの穴を避けた画面の内側にHUDを置くことの2つです。

マウスの感覚のまま作ると、1回のタップでジャンプが2回出たり、右上に置いたポーズボタンがカメラの穴の裏に隠れたりします。縦持ちのつもりで作っても、実機は横向きで起動します。

この記事では、指を番号で覚えて2本指で拡大する書き方と、HUDを欠けた画面の内側へ収める方法を、PCのエディタで確かめながら解説します。

カメラの穴を避けてポーズボタンを置き、2本の指の距離の比で絵を拡大するスマホ画面のイメージ

この記事でわかること

  • 1タップが2回になる理由と、受け取り口を1つに決める考え方
  • 指を番号(index)の辞書で覚える書き方と、押し続けるボタンの作り方
  • 2点の距離からピンチの倍率を出す式
  • ノッチを避ける余白の作り方と、画面の向きの設定

動作確認環境: Godot 4.7(Windows)

Sponsored

タッチとマウスの違い

マウスのカーソルは、押していなくても画面のどこかにいつも1つあります。指は、押した場所に現れて、離すと消えます。2本、3本と同時に触れることもあります。

この違いがあるのに、Godot のプロジェクトは、指とマウスを行き来させる設定を最初から2つ持っています。場所はプロジェクト設定の Input Devices → Pointing です。

  • emulate_mouse_from_touch: 既定でオン。指のタッチから、左マウスボタンの入力も作られます
  • emulate_touch_from_mouse: 既定でオフ。オンにすると、PCの左クリックからタッチも作られます

emulate_mouse_from_touch は切らないでください。これがあるおかげで、マウス向けに作った Control が、実機でも指で動きます。そのかわり、1本の指から2種類の入力が届きます。

1本の指からタッチとマウスの左ボタンの2つが届き、両方でジャンプを呼ぶと2回跳ぶ

指が画面に触れた・離れたときに届く知らせが InputEventScreenTouch です。これで自前の処理を書き、同じことをマウスボタンに割り当てたアクション(Input Map で名前を付けた操作)でもやっていると、1タップで両方が走ります。受け取り口はどちらか一方に決めます。

タップ1回で足りるボタンやメニューは、マウス側に任せるのが早道です。Button を置けば、そのまま指で押せます。自前で InputEventScreenTouch を読むのは、2本目の指を扱いたいときです。

Sponsored

指の番号(index)と押し続けるボタン

指には番号が付いています。InputEventScreenTouch と、指を動かしたときに届く InputEventScreenDrag の index がそれで、触れているあいだ同じ番号が付き続けます。最初の1本はたいてい 0 ですが、押した順に 0・1 と並ぶとは決まっていません。先に 0 を離すと、残った指は 1 のままです。

指0が触�れ、指1が加わり、指0が離れる。辞書touchesには、いま触れている番号だけが残る

管理の形は、キーと値を組にして覚えておく辞書(Dictionary)1つで足ります。index をキーにして位置を入れ、離されたときと取り消されたときにキーを消します。受け取るのは、ボタンなどの UI が使わなかった入力が1つずつ届く _unhandled_input() です。

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventScreenTouch:
		if event.canceled or not event.pressed:
			touches.erase(event.index)
		else:
			touches[event.index] = event.position
	elif event is InputEventScreenDrag:
		if touches.has(event.index):
			touches[event.index] = event.position

canceled は、押している途中でOSに取り上げられたときに立ちます。通知バーを引き下ろしたときなどがこれで、見落とすと指が辞書に残り続けます。

画面にボタンを置くだけなら、辞書は要りません。4.7 の Button は指ごとに押されるので、移動とジャンプの2つの Button を2本の指で同時に押せます。

ただ、Button の pressed は離したときに1回だけ来ます。移動ボタンのように、押しているあいだずっと効かせたいところは TouchScreenButton を使います。

Buttonのpressedは離したときに1回だけ、TouchScreenButtonは押しているあいだアクションが入り続ける
  • Node2D の仲間なので、Control を画面の角や辺に貼り付けるアンカーの仕組みには乗りません。画面の角に置くなら、位置を自分で入れます
  • action に Input Map のアクション名を入れると、押しているあいだそのアクションが入り続けます。キーボードと同じ Input.get_vector() でそのまま読めます
  • 押す範囲は Shape(形)で決めます。空なら Texture Normal の絵の矩形です。どちらも空だと押せません

ピンチで拡大する計算

拡大は、2本の指の距離だけで出せます。

2本目が乗った瞬間の開始距離240が今の距離360になると、360÷240で絵が1.5倍になる

2本目が乗った瞬間の距離と、そのときの大きさを覚えます。あとは「今の距離 ÷ 開始距離」を掛けるだけです。

if start_distance <= 0.0:
	start_distance = distance          # 2本目が乗った瞬間の距離
	start_scale = zoomable.scale       # そのときの大きさ
zoomable.scale = start_scale * (distance / start_distance)

開始時の大きさを覚えておくのが要点です。これが無いと、2回目のピンチで毎回等倍へ戻ります。

セーフエリア(ノッチを避けた範囲)

PCのウィンドウは長方形ですが、スマホの画面は角が丸く、上にカメラの穴があります。その穴を避けた「置いていい範囲」を返すのが DisplayServer.get_display_safe_area() です。

スマホの画面全体と、カメラの穴を避けたsafe areaの差が、上下左右の余白になる

戻ってくるのは Rect2i(整数の矩形)で、単位は画面ピクセルです。ビューポート(ゲームの絵を描く領域)の座標とは原点も倍率も違うので、そのまま余白に使えません。合わせる手順は3つです(実践の _apply_safe_area())。

  1. ウィンドウの位置を引いて、ウィンドウの左上を原点にする
  2. ビューポートの大きさ ÷ ウィンドウの大きさ、の比率を掛ける
  3. 負の値は 0 に切る

こうして出した上下左右の余白を、画面いっぱいに広げた MarginContainer(子を、上下左右に決めた余白の内側へ収める入れ物)に入れます。余白はインスペクタの Theme Overrides の Constants にある値なので、コードからは add_theme_constant_override() で入れます。HUDの中身をこのコンテナの子にしておけば、穴の下へまとめて寄ります。

PCのふつうのウィンドウで動かすと、4つとも 0 になります。欠けていない画面では、0 が正しい答えです。

画面の向きと、大きさが変わったときの掛け直し

プロジェクト設定の display/window/handheld/orientation が、実機で許す向きを決めます。既定は Landscape なので、縦で設計していても実機は横で起動します。

縦長にすると、角に合わせたボタンは角に付いてくるが、固定座標の四角は画面の外へ出る

縦持ち専用なら Portrait、縦のまま上下の回転だけ許すなら Sensor Portrait、端末の向きに任せるなら Sensor を選びます。

この設定は、PCのウィンドウを回しません。エディタで確かめるときは、実行中のウィンドウの辺をドラッグして、幅と高さを入れ替えます。

ビューポートの大きさが変わると size_changed シグナルが来るので、そこで余白を掛け直します。

func _ready() -> void:
	get_viewport().size_changed.connect(_apply_safe_area)
	_apply_safe_area()

アンカーやコンテナで角に寄せた Control は、これで角に残ります(Controlレイアウト入門)。追いかけてこないのは、Node2D に固定の座標を入れたもののほうです。TouchScreenButton を角に置いたなら、ここで位置を入れ直します。

Sponsored

実践:2本指の拡大とセーフエリアをPCで確かめる

パズルの盤、図鑑の絵、写真ビューア——2本指で拡大する操作はどれも同じ形です。実機に入れなくても正誤が分かるように、いまの値を画面へ出します。

① 置くもの

新しいシーンを1つ作ります。絵はプロジェクトに最初から入っている icon.svg だけです。

まずプロジェクト設定で3つ決めます。

  • input_devices/pointing/emulate_touch_from_mouse をオンにします。PCのマウスを指0として使うためで、書き出す前に戻します。オンにしても作れる指は1本だけなので、2本目はキーボードで足します
  • display/window/stretch/mode を disabled にします。既定の canvas_items(基準の解像度で描いて、ウィンドウに合わせて拡大する方式)のままだと、ウィンドウとビューポートの数字が一致しません。これも確かめるあいだだけです
  • display/window/handheld/orientation を Sensor にします。実機へ入れたとき、端末の向きに合わせて回るようになります

ルートに Node2D を置いて Main、main.gd を付けます。

  • Main の子に Node2D を置いて Zoomable。位置 (576, 400)。その子に Sprite2D(icon.svg)
  • Main の子に Sprite2D を2つ、Finger0 と Finger1。icon.svg・Scale (0.3, 0.3)・Modulate のアルファ 0.5・Visible をオフ
  • Main の子に CanvasLayer(カメラや拡大と関係なく、画面に固定して描く層)を置いて Hud
  • Hud の子に ColorRect を置いて FakeNotch。Anchors Preset は Top Wide、高さ 80、Mouse Filter を Ignore(触っても素通りさせる)、Visible をオフ
  • Hud の子に MarginContainer を置いて SafeMargin。Anchors Preset は Full Rect。Mouse Filter は既定の Pass のままで、指は下のゲームまで届く
  • SafeMargin の子に Label を置いて InfoLabel。Container Sizing の Horizontal と Vertical を両方 Shrink Begin
  • SafeMargin の子に Button を置いて ResetButton。テキストは「リセット」。Container Sizing の Horizontal を Shrink End、Vertical を Shrink Begin
  • Zoomable・Finger0・Finger1・FakeNotch・SafeMargin・InfoLabel・ResetButton は、固有名でアクセスする項目(英語UIでは「Access as Unique Name」)をオンにする。コードからは %SafeMargin のように % を付けて取る

MarginContainer は子をぜんぶ同じ矩形に置くので、ラベルを左上、ボタンを右上へ分けているのが Container Sizing です。指定しないと、ボタンが矩形いっぱいに広がります。

② 1本のスクリプト

# main.gd
extends Node2D

const FAKE := 99                         # 仮の2本目の番号(実機の指の番号と重ならない)
const FAKE_OFFSET := Vector2(240, 0)
const FAKE_NOTCH := 80

var touches := {}                        # index -> Vector2(ビューポート座標)
var start_distance := 0.0
var start_scale := Vector2.ONE
var notch := 0

@onready var margin: MarginContainer = %SafeMargin
@onready var info: Label = %InfoLabel
@onready var zoomable: Node2D = %Zoomable
@onready var fingers: Array[Sprite2D] = [%Finger0, %Finger1]

func _ready() -> void:
	get_viewport().size_changed.connect(_apply_safe_area)
	%ResetButton.pressed.connect(_on_reset_pressed)
	_apply_safe_area()

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventScreenTouch:
		if event.canceled or not event.pressed:
			touches.erase(event.index)
			if event.index == 0:
				touches.erase(FAKE)               # 仮の2本目も一緒に外す
		else:
			touches[event.index] = event.position
	elif event is InputEventScreenDrag:
		if touches.has(event.index):          # 押し始めを見ていない指は拾わない
			touches[event.index] = event.position
	elif event is InputEventKey and event.pressed and not event.echo:
		if event.keycode == KEY_2:            # 仮の2本目を置く・外す
			if touches.has(FAKE):
				touches.erase(FAKE)
			elif touches.has(0):
				touches[FAKE] = touches[0] + FAKE_OFFSET
		elif event.keycode == KEY_N:          # 仮のノッチを出す・消す
			notch = 0 if notch > 0 else FAKE_NOTCH
			%FakeNotch.visible = notch > 0
			_apply_safe_area()
	_refresh()

func _refresh() -> void:
	var keys := touches.keys()
	keys.sort()
	for i in fingers.size():
		fingers[i].visible = i < keys.size()
		if fingers[i].visible:
			fingers[i].global_position = touches[keys[i]]
	var distance := 0.0
	if keys.size() >= 2:
		distance = touches[keys[0]].distance_to(touches[keys[1]])
		if start_distance <= 0.0:
			start_distance = distance         # 2本目が乗った瞬間の距離
			start_scale = zoomable.scale      # そのときの大きさ
		if start_distance > 0.0:
			zoomable.scale = (start_scale * (distance / start_distance)).maxf(0.2)   # 0.2倍より小さくしない
	else:
		start_distance = 0.0
	info.text = "指 %d本 / 距離 %d" % [keys.size(), distance]

func _on_reset_pressed() -> void:
	zoomable.scale = Vector2.ONE              # 絵を等倍へ戻す
	start_distance = 0.0

func _apply_safe_area() -> void:
	var safe := DisplayServer.get_display_safe_area()
	var win_pos := Vector2(DisplayServer.window_get_position())
	var win_size := Vector2(DisplayServer.window_get_size())
	var view := get_viewport().get_visible_rect().size
	var ratio := view / win_size
	var origin := (Vector2(safe.position) - win_pos) * ratio   # 画面ピクセルからビューポートの単位へ
	var far := (Vector2(safe.end) - win_pos) * ratio
	margin.add_theme_constant_override("margin_left", ceili(maxf(origin.x, 0.0)))
	margin.add_theme_constant_override("margin_top", maxi(ceili(maxf(origin.y, 0.0)), notch))
	margin.add_theme_constant_override("margin_right", ceili(maxf(view.x - far.x, 0.0)))
	margin.add_theme_constant_override("margin_bottom", ceili(maxf(view.y - far.y, 0.0)))

仮の2本目には、実機の指には付かない番号 99 を使います。実機で本物の2本目が触れているときに、それを巻き込んで消さないためです。

③ 触って確かめる

再生して、画面の左寄りでマウスの左ボタンを押したままにします。右へ 240 以上の余裕がある場所を選んでください。見るのは2つです。

2キーで指0の右240に仮の指1を置き、Nキーで上端に高さ80の帯を出してリセットを下げる

1つ目は指とピンチです。押した場所に Finger0 が現れ、ラベルは「指 1本」になります。2 キーを押すと右 240 の位置に Finger1 が現れ、「指 2本 / 距離 240」に変わりますが、絵の大きさはまだ変わりません。そのままマウスを左へ動かすと、仮の2本目は動かないので距離が開き、「距離 360」で絵が1.5倍になります。もう一度 2 キーを押すと「指 1本」に戻り、大きさはそのまま残ります。「リセット」を押すと等倍に戻ります。

2つ目はノッチと向きです。N キーを押すと、上端に高さ 80 の帯が出て、ラベルと「リセット」がその下へ下がります。実行中のウィンドウの辺をドラッグして、幅を 600 ほど、高さを 900 ほどの縦長にすると、「リセット」は右上の角に付いてきます。 帯を出しても縦長にしても、HUDは欠けた部分の内側の角に収まります。 中央にあった絵は固定の座標なので、右端のほうに取り残されます。

指1本、2本で距離240、左の指だけ動かして距離360で絵が1.5倍、Nキーで帯の下へHUDが下がる

PCで再現できないものも2つあります。仮ノッチは上端に固定で、実機のように横へ回りません。Finger1 はキーで置いた点なので、本物の2本目のように番号が入れ替わることはありません。この2つだけは実機で確かめます。

ポイントは2つです。

  • 受け取り口を1つに決める: タップだけで足りるものは Button に任せ、InputEventScreenTouch を読むのは2本目が要るところだけにします。両方で同じ処理を呼ぶと2回走ります
  • 欠けた範囲は余白にして、大きさが変わるたびに掛け直す: size_changed につないでおけば、縦横が入れ替わってもHUDは角に残ります

確かめ終わったら、emulate_touch_from_mouse をオフに、stretch/mode を canvas_items に戻してください。オンのままデスクトップ向けに書き出すと、マウスの操作がタッチとしても届き続けます。

うまくいかないときは

症状原因直し方
HUDを置いたら、画面のどこを触っても指が出ない画面いっぱいに広げた ColorRect や Control の Mouse Filter が、既定の Stop のままIgnore にする。MarginContainer のようなコンテナは既定が Pass なので、そのままでよい
ドラッグ中にボタンの上を通ると、指の丸が止まるボタン(Stop)が、その上を通るドラッグを処理済みにしているドラッグだけ _input()(UIより先に入力が届く関数)で受ける。押し始めまで移すと、ボタンの下のゲームも反応する
カメラを動かすと、画面の TouchScreenButton も一緒に流れていくゲームの絵と同じ場所(CanvasLayer の外)に置いているCanvasLayer の子にする
縦長にすると、ラベルとボタンが重なる。ボタンが画面の外へ出るLabel は文字の幅より縮まないので、コンテナごと画面より広くなるラベルの文字を短くするか、Autowrap Mode を入れて折り返す

おまけ:先に知っておくと良いこと

  • 黒帯が出るストレッチのとき: 余白の式は、描画領域がウィンドウいっぱいに広がっている前提です。aspect を keep にして黒帯を出すなら、描画領域が中央へずれたぶんも引きます。決め方は Godotプロジェクト最初の設定 にあります
  • 端末を180度回したとき: 画面の大きさは同じままで、ノッチだけが反対側へ移ります。size_changed は来ないので、余白は前の側に残ります。get_display_safe_area() をときどき読み直して、値が変わったときに掛け直します
  • 指のあいだを中心に拡大したいとき: この記事の拡大は、絵の中心(ノードの原点)が軸です。地図のように指のあいだの点を動かさずに拡大するなら、拡大のたびに、その点が同じ画面位置に来るよう位置もずらします
  • 実機に入れるところ: Android への書き出しと署名は エクスポート設定 にあります。画面上のスティックを作るなら、中心付近を捨てる考え方は ゲームパッド入門 のデッドゾーンと同じです
Sponsored

まとめ

  • タッチはマウスにもなる: 既定で、指のタッチから左マウスボタンの入力も作られる。両方で同じ処理を呼ぶと2回走る
  • 指は index で覚える: 辞書に入れ、離されたときと canceled のときに消す
  • 押し続けるなら TouchScreenButton: 押しているあいだ効かせたいボタンは、action を入れた TouchScreenButton にする
  • ピンチは距離の比: 「今の距離 ÷ 開始距離」。開始時の大きさも一緒に覚える
  • セーフエリアは単位を直す: 画面ピクセルで返るので、ビューポートの単位へ換算する。向きが変わったら size_changed で掛け直す

HUDを MarginContainer の中へ入れても、PCでは何も変わりません。変わらないのが、スマホで変わる用意ができたという印です。あなたのゲームのHUDは、どのコンテナの中にありますか。

次に読む次のカテゴリ「物理・移動」へ
物理・移動

GodotのVector2入門:マウスを追う砲塔を作り、距離が変わっても弾の速さを一定にする

この記事を読む
Godot このセクションのノート104