[Unity]よくあるスマホFPSゲームの移動とカメラのマルチタッチ操作

# こういうの
https://twitter.com/flankids/status/1399434696874881024

* 青のポインタが左手の指で、移動操作
* 黄緑のポインタが右手の指で、カメラ操作（エイム）

マルチタッチの管理って今まであんまりちゃんと向き合ってこなかったので、どう実装するかやってみた。
そんなに珍しいものでもないと思うけど、いざやってみると「この実装でいいんだっけ・・・」ってなったのでツッコミ待ち的な目的でちょっとまとめてみます。
**プルリク的なノリで見てもらえると嬉しいです。**

本記事のUnityプロジェクトはGithubで公開しています。

https://github.com/fluncle/MultiTouchControl

# スワイプ操作を管理する`DragHandler`コンポーネントを作成

```DragHandler.cs
using System;
using UnityEngine;
using UnityEngine.EventSystems;

public class DragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    public Action<PointerEventData> OnBeginDragEvent;

    public Action<PointerEventData> OnDragEvent;

    public Action<PointerEventData> OnEndDragEvent;

    public void OnBeginDrag(PointerEventData eventData)
    {
        OnBeginDragEvent?.Invoke(eventData);
    }

    public void OnDrag(PointerEventData eventData)
    {
        OnDragEvent?.Invoke(eventData);
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        OnEndDragEvent?.Invoke(eventData);
    }
}
```

ドラッグ操作のコールバックを取得する各種インターフェースを継承した、`DragHandler.cs`を作りました。

* IBeginDragHandler
* IDragHandler
* IEndDragHandler

外部から各種`Action`メンバにドラッグ開始、ドラッグ中、ドラッグ終了のコールバックで呼び出す処理を渡せるようにして、汎用的に使えるようにしています。

# タッチ判定を設置

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/baf4a1ff-9f12-f651-8647-cbcfa1dafcd7.png)


画面に対して↑のように、画面半分ずつ覆うImageオブジェクトを配置します。
※わかりやすいよう可視化してますが、実際にはαを0にして透明にしています

**それぞれに先ほどの`DragHandler.cs`コンポーネントを追加しておきます。**

# プレイヤーの移動を`DragHandler.cs`を使って実装

```Player.cs
using UnityEngine;
using UnityEngine.EventSystems;

public class Player : MonoBehaviour
{
    /// <summary> 移動操作を受け付けるタッチエリア </summary>
    [SerializeField]
    private DragHandler _moveController;

    /// <summary> 移動速度（m/秒） </summary>
    [SerializeField]
    private float _movePerSecond = 7f;

    /// <summary> 移動操作としてタッチ開始したスクリーン座標 </summary>
    private Vector2 _movePointerPosBegin;

    private Vector3 _moveVector;

    /// <summary> 起動時 </summary>
    private void Awake()
    {
        _moveController.OnBeginDragEvent += OnBeginDragMove;
        _moveController.OnDragEvent += OnDragMove;
        _moveController.OnEndDragEvent += OnEndDragMove;
    }

    /// <summary> 更新処理 </summary>
    private void Update()
    {
        UpdateMove(_moveVector);
    }

    ////////////////////////////////////////////////////////////
    /// 移動操作
    ////////////////////////////////////////////////////////////
    #region Move

    /// <summary> ドラッグ操作開始（移動用） </summary>
    private void OnBeginDragMove(PointerEventData eventData)
    {
        // タッチ開始位置を保持
        _movePointerPosBegin = eventData.position;
    }

    /// <summary> ドラッグ操作中（移動用） </summary>
    private void OnDragMove(PointerEventData eventData)
    {
        // タッチ開始位置からのスワイプ量を移動ベクトルにする
        var vector = eventData.position - _movePointerPosBegin;
        _moveVector = new Vector3(vector.x, 0f, vector.y);
    }

    private void UpdateMove(Vector3 vector)
    {
        // 現在向きを基準に、入力されたベクトルに向かって移動
        transform.position += transform.rotation * vector.normalized * _movePerSecond * Time.deltaTime;
    }

    /// <summary> ドラッグ操作終了（移動用） </summary>
    private void OnEndDragMove(PointerEventData eventData)
    {
        // 移動ベクトルを解消
        _moveVector = Vector3.zero;
    }
    #endregion
}
```

プレイヤーとして操作するオブジェクトに付与するコンポーネントとして、`Player.cs`を作ります。
メンバ`_moveController`に、先ほど`DragHandler.cs`を付与したImageオブジェクトのうち、左の方をInspectorで渡しておきます。

**これで画面左半分をタッチ起点としたスワイプが移動操作として反応するようになりました。**

# カメラの回転を`DragHandler.cs`を使って実装

## `DragHandler.cs`にキャンバス上の座標取得する処理を追加

カメラ回転はスワイプ量によって回転角度が変わる処理を扱います。
**dpiによって操作感が変わることを避ける**ため、タッチしたスクリーン座標をキャンバス上でのタッチ座標（ひいては操作量）に変換する処理を追加します。

### メンバ追加
```DragHandler.cs
/// <summary> 自身が所属してるキャンバス </summary>
private Canvas _belongedCanvas;
```

### メソッド追加
```DragHandler.cs
/// <summary>
/// スクリーン座標を自身が所属してるキャンバス上の座標に変換
/// </summary>
/// <param name="pointerPos">クリーン座標</param>
/// <returns>自身が所属してるキャンバス上の座標</returns>
public Vector2 GetPositionOnCanvas(Vector2 pointerPos)
{
    if(_belongedCanvas == null)
    {
        _belongedCanvas = GetBelongedCanvas(transform);
    }
    RectTransformUtility.ScreenPointToLocalPointInRectangle(_belongedCanvas.transform as RectTransform, pointerPos, _belongedCanvas.worldCamera, out Vector2 localPointerPos);
    return localPointerPos;
}

/// <summary> 所属するCanvasを取得 </summary>
private Canvas GetBelongedCanvas(Transform t)
{
    if (t == null)
    {
        return null;
    }

    var canvas = t.GetComponent<Canvas>();
    if (canvas != null)
    {
        return canvas;
    }

    return GetBelongedCanvas(t.parent);
}
```

## `Player.cs`にカメラ操作に関する処理を追加

### メンバ追加
```Player.cs
[SerializeField]
private Camera _camera;

/// <summary> カメラ操作を受け付けるタッチエリア </summary>
[SerializeField]
private DragHandler _lookController;

/// <summary> カメラ速度（°/px） </summary>
[SerializeField]
private float _angularPerPixel = 1f;

/// <summary> カメラ操作として前フレームにタッチしたキャンバス上の座標 </summary>
private Vector2 _lookPointerPosPre;
```

### Awake処理追加
```Player.cs
private void Awake()
{
    _moveController.OnBeginDragEvent += OnBeginDragMove;
    _moveController.OnDragEvent += OnDragMove;
    _moveController.OnEndDragEvent += OnEndDragMove;

    // ここからが追加分
    _lookController.OnBeginDragEvent += OnBeginDragLook;
    _lookController.OnDragEvent += OnDragLook;
}
```

### メソッド追加
```Player.cs
////////////////////////////////////////////////////////////
/// カメラ操作
////////////////////////////////////////////////////////////
#region Look
/// <summary> ドラッグ操作開始（カメラ用） </summary>
private void OnBeginDragLook(PointerEventData eventData)
{
    _lookPointerPosPre = _lookController.GetPositionOnCanvas(eventData.position);
}

/// <summary> ドラッグ操作中（カメラ用） </summary>
private void OnDragLook(PointerEventData eventData)
{
    var pointerPosOnCanvas = _lookController.GetPositionOnCanvas(eventData.position);
    // キャンバス上で前フレームから何px操作したかを計算
    var vector = pointerPosOnCanvas - _lookPointerPosPre;
    // 操作量に応じてカメラを回転
    LookRotate(new Vector2(-vector.y, vector.x));
    _lookPointerPosPre = pointerPosOnCanvas;
}

private void LookRotate(Vector2 angles)
{
    Vector2 deltaAngles = angles * _angularPerPixel;
    transform.eulerAngles += new Vector3(0f, deltaAngles.y);
    _camera.transform.localEulerAngles += new Vector3(deltaAngles.x, 0f);
}
#endregion
```

メンバ`_lookController`に、先ほど`DragHandler.cs`を付与したImageオブジェクトのうち、右の方をInspectorで渡しておきます。
また、シーン中の`Main Camera`をプレイヤーオブジェクトの視点の位置にアタッチして、`Player.cs`のメンバ`_camera`にInspectorで渡しておきます。

**これで画面右半分をタッチ起点としたスワイプがカメラ操作として反応するようになりました。**

これで完成です。

# 所感

`IDragHandler`でタッチ管理すると、タッチ開始位置さえタッチ判定のRectTransform内であれば、以降は**判定外までスワイプしても同じコンポーネント内で操作を管理してくれる**から楽ですね。
これ系のインターフェースは**タッチ位置が対象オブジェクトの矩形外になったら何も反応しなくなると思ってた**ので意外でした。

それを知るまでは`Input.GetTouch`でタッチ情報を管理して、マルチタッチ中にどちらかの指が離されたら賢くインデックス値を切り替えて・・・みたいなことをしないといけないと思い込んでました。（マジで今更すぎる・・・）

ただ、いつだったか、「画面全体を覆うような`Raycast Target`オブジェクトは負荷が高くて非推奨」みたいなことを聞いたことがある気がするので、今回の実装はめちゃくちゃ楽だけど実はNGパターンだったりするんでしょうか・・・？知ってる人いたら教えてほしいです。

あと、これも今回知ったことですが、`OnBeginDrag`が呼び出されるのは画面タッチした瞬間ではなく、タッチした指（あるいはカーソル）が**一定距離動くまで呼び出されない**みたいですね。
この記事では実装してないですが、これのおかげでピンチイン/アウト操作との共存もやりやすかったりしました。

こういう「よくある仕様」を復習がてら実装すると発見があるのでまた何か思いついたらやってみたいです。
今回はここまで。