PCでは快適に遊べていたゲームを、いざAndroidやiPhoneで動かしてみると——ピンチでズームできない、ボタンを押したつもりがキャラも一緒に動く、スコア表示がノッチの裏に隠れて読めない。 スマホ対応の問題は、実機に入れた瞬間、全部まとめて自分ごとになります。
この記事では、New Input System の Enhanced Touch を軸に、タップ・ドラッグ・長押し・ピンチというモバイルの基本操作と、UIとの競合を防ぐガード、そして Safe Area 対応までを一気に整えます。最後は「1本指でパン、2本指でピンチズームできる見下ろしマップ」を完成させます。
この記事でわかること
- マウス互換だけでは足りない理由と Enhanced Touch の有効化
- タップ・ドラッグ・長押し をしきい値で判定する定番ロジック
- 2本指の ピンチ(ズーム)とパン の計算
- UI上のタッチをゲーム操作へ通さない ガード(
IsPointerOverGameObject)Screen.safeAreaでノッチ・パンチホールを避けるUI配置- 実践:ピンチズームできる見下ろしマップを完成させる
動作確認環境: Unity 2022.3 LTS / Unity 6・Input System 1.7+
マウス互換だけでは足りない理由
Unityはタッチをある程度マウスとして扱ってくれるので、「タップ=クリック」だけのゲームなら、PC向けのコードがそのまま動くこともあります。それでもタッチ専用の書き方が必要になるのは、タッチとマウスが根本的に違うからです。
- 指は複数ある: マウスカーソルは常に1つですが、指は2本、3本と同時に画面へ触れます。ピンチズームはその代表です
- 「触れていない時の位置」が無い: マウスは動かすだけで位置が取れますが、指は触れた瞬間に突然現れ、離れた瞬間に消えます。ホバー(マウスオーバー)も存在しません
- 1本の指には一生がある: 触れる(began)→ 動く(moved)→ 離れる(ended)という 状態の流れ を追いかけて、初めてドラッグや長押しを区別できます
この「複数の指を、IDと状態付きで追いかける」ためのAPIが、New Input Systemの Enhanced Touch です。
Enhanced Touchを有効にする
Enhanced Touchは省電力のため標準では眠っています。使う前に1回だけ有効化してください。
using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
// Touch型の名前が衝突しないよう、別名を付けておくと安全
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
public class TouchBootstrap : MonoBehaviour
{
void OnEnable()
{
EnhancedTouchSupport.Enable();
}
void OnDisable()
{
EnhancedTouchSupport.Disable();
}
}
有効化すると、 Touch.activeTouches で「いま画面に触れているすべての指」が読めるようになります。
void Update()
{
foreach (Touch touch in Touch.activeTouches)
{
// 指ごとに、ID・位置・状態が取れる
Debug.Log($"指{touch.touchId}: {touch.screenPosition} ({touch.phase})");
}
}
touch.phase が指の一生を表します。Began(触れた瞬間)→ Moved / Stationary(動いた・止まっている)→ Ended / Canceled(離れた・中断)。以降のジェスチャー判定は、ぜんぶこの流れの読み取りです。
タップ・ドラッグ・長押しを判定する
1本指の基本ジェスチャーは、 「どれだけ動いたか」と「どれだけ長く触れていたか」の2つのしきい値 で区別できます。

- タップ: ほぼ動かないまま、短時間で離れた
- ドラッグ: 距離のしきい値を超えて動いた(動いている間じゅう継続)
- 長押し: ほぼ動かないまま、時間のしきい値を超えた
using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
public class GestureDetector : MonoBehaviour
{
[SerializeField] private float dragThreshold = 20f; // これ以上動いたらドラッグ(ピクセル)
[SerializeField] private float holdTime = 0.5f; // これ以上触れていたら長押し(秒)
private Vector2 startPosition;
private float startTime;
private bool isDragging;
private bool holdFired;
void Update()
{
if (Touch.activeTouches.Count != 1) return;
Touch touch = Touch.activeTouches[0];
switch (touch.phase)
{
case UnityEngine.InputSystem.TouchPhase.Began:
startPosition = touch.screenPosition;
startTime = Time.time;
isDragging = false;
holdFired = false;
break;
case UnityEngine.InputSystem.TouchPhase.Moved:
case UnityEngine.InputSystem.TouchPhase.Stationary:
// 距離しきい値を超えたら、以降はドラッグ扱い
if (!isDragging && Vector2.Distance(touch.screenPosition, startPosition) > dragThreshold)
{
isDragging = true;
}
// 動かないまま時間しきい値を超えたら長押し(1回だけ発火)
if (!isDragging && !holdFired && Time.time - startTime > holdTime)
{
holdFired = true;
Debug.Log("長押し!");
}
if (isDragging)
{
Vector2 delta = touch.delta;
Debug.Log($"ドラッグ中: {delta}");
}
break;
case UnityEngine.InputSystem.TouchPhase.Ended:
// 動かず・長押しにもならず離れた=タップ
if (!isDragging && !holdFired)
{
Debug.Log("タップ!");
}
break;
}
}
}
しきい値の値はゲーム次第ですが、 ドラッグ判定は20ピクセル前後・長押しは0.5秒前後 が体感の出発点として無難です。「タップのつもりが少し指が滑る」のは普通のことなので、距離0でタップ判定しないのがコツです。
2本指:ピンチとパン
指が2本になったら、ジェスチャーは 2点の距離と中点 で考えます。

- ピンチ(ズーム): 2本の指の 距離の変化。広がれば拡大、縮まれば縮小
- パン(平行移動): 2本の指の 中点の移動
private float previousDistance;
void HandleTwoFingers()
{
Touch t0 = Touch.activeTouches[0];
Touch t1 = Touch.activeTouches[1];
float currentDistance = Vector2.Distance(t0.screenPosition, t1.screenPosition);
// どちらかの指が触れた直後は「前回の距離」が無いので記録だけして抜ける
if (t0.phase == UnityEngine.InputSystem.TouchPhase.Began ||
t1.phase == UnityEngine.InputSystem.TouchPhase.Began)
{
previousDistance = currentDistance;
return;
}
// 距離の変化量がズーム量になる(プラス=広げた=ズームイン)
float pinchDelta = currentDistance - previousDistance;
previousDistance = currentDistance;
Debug.Log($"ピンチ: {pinchDelta}");
}
大事なのは、 指が増減した瞬間に「前回の値」をリセットする ことです。これを忘れると、2本目の指が触れた瞬間にカメラが跳ねる「ズームの暴発」が起きます。実践のコードで、この処理を組み込んだ完成形を示します。
UIの上のタッチをゲームに通さない
モバイルで必ず起きる事故がこれです。ポーズボタンをタップしたら、ボタンの下のマップまでドラッグ判定されて画面がずれる——タッチは1回なのに、 UIとゲームの両方が反応してしまう 問題です。

定番のガードは、タッチの開始時に 「その指はUIの上にいるか?」 を確認することです。
using UnityEngine.EventSystems;
bool IsTouchOnUI(Touch touch)
{
// touchIdを渡すのがポイント(引数なし版はタッチで正しく動かないことがある)
return EventSystem.current != null &&
EventSystem.current.IsPointerOverGameObject(touch.touchId);
}
ゲーム操作の入口で、UI上で始まったタッチを弾きます。「UIに触れた指はUIのもの、それ以外の指はゲームのもの」という所有権のルールを最初に決めておくと、以降のジェスチャー処理が濁りません。
注意: この判定にはシーン内に
EventSystemが必要です(uGUIのCanvasを作ると自動で入ります)。また、New Input Systemを使うプロジェクトでは、EventSystemのコンポーネントがInputSystemUIInputModuleになっていることを確認してください(古いStandaloneInputModuleのままだとUIがタッチに反応しません)。
Screen.safeAreaでノッチを避ける
操作の次は表示です。最近のスマホは、ノッチ・パンチホール・角丸で 画面の四隅が欠けています。何も対策しないと、左上に置いたスコアがノッチの裏に隠れます。

Unityは Screen.safeArea で「表示とタッチが安全な長方形」を教えてくれます。UIのルートに次のスクリプトを置き、アンカーをSafe Areaへ合わせるのが定番です。
using UnityEngine;
// Canvas直下の「UIルート」パネルに付ける
public class SafeAreaFitter : MonoBehaviour
{
private RectTransform rectTransform;
private Rect lastSafeArea;
void Awake()
{
rectTransform = GetComponent<RectTransform>();
Apply();
}
void Update()
{
// 画面回転などでSafe Areaが変わったら追従する
if (Screen.safeArea != lastSafeArea)
{
Apply();
}
}
void Apply()
{
Rect safeArea = Screen.safeArea;
lastSafeArea = safeArea;
// Safe Areaのピクセル座標を、0〜1のアンカーへ変換する
Vector2 anchorMin = safeArea.position;
Vector2 anchorMax = safeArea.position + safeArea.size;
anchorMin.x /= Screen.width;
anchorMin.y /= Screen.height;
anchorMax.x /= Screen.width;
anchorMax.y /= Screen.height;
rectTransform.anchorMin = anchorMin;
rectTransform.anchorMax = anchorMax;
rectTransform.offsetMin = Vector2.zero;
rectTransform.offsetMax = Vector2.zero;
}
}
使い方のコツは、 Canvasの直下に「SafeAreaパネル」を1枚挟み、スコアやボタンは全部その子にする ことです。逆に、背景画像や全画面のエフェクトはSafeAreaパネルの外(Canvas直下)に置き、画面いっぱいまで広げます。「情報と操作は安全圏内、雰囲気は画面いっぱい」が使い分けの基準です。Canvasとアンカーの基礎 が曖昧な場合は、先にそちらを読むとこのスクリプトの意味がすっと入ります。
実践:ピンチズームできる見下ろしマップ
部品が揃ったので、1つの操作系として組み上げます。ストラテジーのマップ画面、タワーディフェンスの配置画面、街づくりゲームの俯瞰視点——「1本指でパン、2本指でピンチズーム、UIは誤爆しない」はそのまま使い回せる骨格です。

見下ろしカメラ(Orthographic)に付ける完成コードです。
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;
[RequireComponent(typeof(Camera))]
public class TouchMapCamera : MonoBehaviour
{
[SerializeField] private float panSpeed = 1f;
[SerializeField] private float zoomSpeed = 0.05f;
[SerializeField] private float minZoom = 3f;
[SerializeField] private float maxZoom = 15f;
private Camera cam;
private float previousPinchDistance;
private int previousTouchCount;
void OnEnable()
{
EnhancedTouchSupport.Enable();
}
void OnDisable()
{
EnhancedTouchSupport.Disable();
}
void Awake()
{
cam = GetComponent<Camera>();
}
void Update()
{
// UI上で始まったタッチは、このカメラの操作に使わない
int gameTouchCount = 0;
Touch first = default;
Touch second = default;
foreach (Touch touch in Touch.activeTouches)
{
if (IsTouchOnUI(touch)) continue;
if (gameTouchCount == 0) first = touch;
else if (gameTouchCount == 1) second = touch;
gameTouchCount++;
}
// 指の本数が変わった瞬間は「前回の値」を捨てる(ズーム・パンの暴発防止)
if (gameTouchCount != previousTouchCount)
{
if (gameTouchCount == 2)
{
previousPinchDistance =
Vector2.Distance(first.screenPosition, second.screenPosition);
}
previousTouchCount = gameTouchCount;
return;
}
if (gameTouchCount == 1)
{
Pan(first);
}
else if (gameTouchCount == 2)
{
Pinch(first, second);
}
}
void Pan(Touch touch)
{
if (touch.phase != TouchPhase.Moved) return;
// 画面上の指の移動を、ワールドの移動量へ換算する
float unitsPerPixel = cam.orthographicSize * 2f / Screen.height;
Vector2 delta = touch.delta * unitsPerPixel * panSpeed;
// 指の動きと逆方向へカメラを動かすと「マップを掴んで動かす」感触になる
transform.position -= new Vector3(delta.x, 0f, delta.y);
}
void Pinch(Touch t0, Touch t1)
{
float currentDistance = Vector2.Distance(t0.screenPosition, t1.screenPosition);
float pinchDelta = currentDistance - previousPinchDistance;
previousPinchDistance = currentDistance;
// 指を広げたら寄る(orthographicSizeを小さく)、縮めたら引く
float newSize = cam.orthographicSize - pinchDelta * zoomSpeed;
cam.orthographicSize = Mathf.Clamp(newSize, minZoom, maxZoom);
}
bool IsTouchOnUI(Touch touch)
{
return EventSystem.current != null &&
EventSystem.current.IsPointerOverGameObject(touch.touchId);
}
}
実機(またはDevice Simulator)で触ってみてください。1本指でマップが「掴んだまま」ついてきて、2本指を広げるとズームイン、画面隅のボタンに触れてもマップは微動だにしない——この3つが揃えば合格です。もしズームの瞬間にカメラが跳ねるなら、指の本数が変わった瞬間のリセット(previousTouchCount の比較)が抜けていないかを見てください。ここがこの操作系でいちばん壊れやすい関節です。
ポイントは2つ。 指の本数が変わる瞬間が一番の事故ポイント(前回の値を必ずリセットする)。 UIの所有権チェックはジェスチャーの入口で1回だけ(各処理の中でバラバラに判定しない)。
おまけ:先に知っておくと良いこと
- Device Simulatorでノッチ込みの確認: エディタのGameビューを Simulator に切り替えると、実在端末のノッチ・Safe Areaを再現できます。実機テストの前の一次確認に十分使えます。ただし複数指のジェスチャーの「感触」は実機でしか分からないので、ピンチの速度調整は実機で行ってください
- タップできる大きさには下限がある: 指はマウスカーソルより太い道具です。ボタンは 物理サイズでおよそ1cm角(44〜48pt) を下回らないように。小さいボタンを並べるより、大きいボタンを減らす方がモバイルでは正解になりやすいです
- 振動(ハプティクス)で手応えを返す:
Handheld.Vibrate()で端末を振動させられます。長押し成立の瞬間などに短く返すと操作の確信感が上がります。細かい強弱の制御はプラットフォームごとのAPIやア セットの領域です
まとめ
- タッチは「複数の指が現れて消える」入力。 Enhanced Touch で指ごとにID・位置・状態を追う
- タップ・ドラッグ・長押しは 距離と時間の2つのしきい値 で判定する
- ピンチは 2点間の距離の変化、パンは 中点の移動。指の本数が変わる瞬間に前回値をリセットする
- UI上のタッチは
IsPointerOverGameObject(touchId)でゲームに通さない - UIは
Screen.safeAreaに合わせたパネルの中へ。背景は画面いっぱいでよい
あなたのゲームをいまDevice Simulatorで開いたら、最初に壊れて見えるUIはどこでしょう——スマホ対応は、その1カ所を直すところから始まります。