Wonderboxの秀逸な俯瞰ビュー操作仕様を模写実装&考察

# Wonderbox: The Adventure Maker

<iframe width="560" height="315" src="https://www.youtube.com/embed/yYHhXTxU5Eg" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

最近 Apple Arcade の **`Wonderbox`** というタイトルにハマりました。
ざっくりいうと**「`ゼルダ`が作れるマリオメーカー」**みたいなゲームです（ホントはもっと魅力があるので語りたいけど本題じゃないので我慢）

とにかく面白くてここ１ヶ月くらいめちゃくちゃ夢中になっていたのですが、遊ぶ中で「どんなハードな地形が作られても快適に遊べるような移動・カメラ操作仕様になってる・・・」ということに気づきました。

「ユーザーがステージを作って遊ぶ」ということがウリのゲームですが、それを支えるのは超優秀な操作仕様で、あらゆるシチュエーションでもストレスなくイメージ通りに動けるよう配慮されていて、これは**Wonderboxじゃなくても応用が効く仕組みだ！**と感じたので、お勉強として模写実装してみました。

本記事はその仕組みと、想定される狙いをまとめたものです。

### Githubでプロジェクト公開しています

本記事ではコードと合わせて機能を記載していますが、フルでコードを載せると煩雑になりそうだったので最低限のものを抜粋しています。
Githubにプロジェクトを公開したので、コンポーネントの設定の仕方やその他細かい挙動の実装については興味がある方はこちらを参照していただけると嬉しいです！

https://github.com/fluncle/BirdsEyeViewController

# 八方向に補正される操作入力

バーチャルパッドによる操作方向は360度ありますが、実際に移動方向に反映される際に、**45度刻みの八方向**に解釈されます。
これは操作によって生まれるプレイヤーの向きのパターンを適度に減らして、位置関係の微妙な位置へのジャンプで落ちたり、壁に引っかかったりということを減らす目的だと思われます。（**[スーパーマリオ3Dランド](https://www.youtube.com/watch?v=5xLtIrdGSFY)**シリーズでも似た仕様が載ってます）

この時ポイントになるのは、「カメラから見た角度に対して八方向」ではなく、「ステージ上のグリッドに対して八方向」であることです。

|<img width="370" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/28230e0a-0ac9-d3e2-4534-60ae402d4a70.png">|<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/21e9fa96-bc40-fda2-365f-0eb69e61e595.png">|
|---|---|
|▲こうではなく・・・|▲こういう感じ|

前述のような操作ミスは、ユーザーがイメージしてる「まっすぐ」とか「斜め」の入力とカメラに映るステージの角度が噛み合わないことが原因と考えられます。
左の図のように「カメラから見た角度に対する方向へ移動」というのは3Dゲームでよくある操作仕様ですが、俯瞰ビューのゲームにおいてはそのような操作ミスを生まないために、「ステージ上のグリッドに沿った方向」に入力を変換してあげることで、ユーザーの入力と実際の移動方向の乖離を減らすことができます。

https://twitter.com/flankids/status/1405171548223275012

いかなる角度でもグリッドに沿った操作に！

### 実装

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

public class Player : MonoBehaviour
{
    /// <summary> I○○○Handlerを継承したタッチ領域 </summary>
    [SerializeField]
    private DragHandler _dragHandler;

    /// <summary> キャンバス </summary>
    [SerializeField]
    private Canvas _canvas;

    /// <summary> カメラのTransform </summary>
    [SerializeField]
    private Transform _camera;

    /// <summary> ドラッグ操作の起点 </summary>
    private Vector2 _basePosition;

    /// <summary> ドラッグ操作のタッチ座標 </summary>
    private Vector2 _dragPosition;

    /// <summary> 最大移動速度(m/秒) </summary>
    [SerializeField]
    private float _speed = 3f;

    /// <summary> 最大回転速度(°/秒) </summary>
    [SerializeField]
    private float _angularVelocity = 360f;

    /// <summary> 最大移動速度に必要なドラッグ量(pixel) </summary>
    [SerializeField]
    private int _dragMaxDistance = 180;

    /// <summary>
    /// 起動時
    /// </summary>
    private void Awake()
    {
        _dragHandler.OnPointerDownEvent = OnPointerDown;
        _dragHandler.OnBeginDragEvent = OnBeginDrag;
        _dragHandler.OnDragEvent = OnDrag;
        _dragHandler.OnEndDragEvent = OnEndDrag;
    }

    /// <summary>
    /// 移動操作領域タッチ時
    /// </summary>
    private void OnPointerDown(PointerEventData e)
    {
        _basePosition = _dragPosition = GetLocalPointOnCanvas(e.position);
    }

    /// <summary>
    /// 移動操作領域ドラッグ開始時
    /// </summary>
    private void OnBeginDrag(PointerEventData e)
    {
        _dragPosition = GetLocalPointOnCanvas(e.position);
    }

    /// <summary>
    /// 移動操作領域ドラッグ時
    /// </summary>
    private void OnDrag(PointerEventData e)
    {
        _dragPosition = GetLocalPointOnCanvas(e.position);

        var dragVector = _dragPosition - _basePosition;
        var dragDistance = dragVector.magnitude;

        // 大きくドラッグ操作したとき、反対方向へすぐ入力が効くように起点とドラッグ座標が一定距離以上離れないようにする
        if (dragDistance > _dragMaxDistance)
        {
            _basePosition += dragVector.normalized * (dragDistance - _dragMaxDistance);
        }
    }

    /// <summary>
    /// 移動操作領域ドラッグ終了時
    /// </summary>
    private void OnEndDrag(PointerEventData e)
    {
        _basePosition = _dragPosition = Vector2.zero;
    }

    /// <summary>
    /// スクリーン上の座標をキャンバス上の座標に変換
    /// 画面解像度に依存しない操作感度を実現するために利用
    /// </summary>
    private Vector2 GetLocalPointOnCanvas(Vector2 screenPos)
    {
        RectTransformUtility.ScreenPointToLocalPointInRectangle(_canvas.transform as RectTransform, screenPos, _canvas.worldCamera, out Vector2 localPoint);
        return localPoint;
    }

    /// <summary>
    /// 更新
    /// </summary>
    private void Update()
    {
        if(_basePosition == _dragPosition)
        {
            // 入力がない時は何もしない
            return;
        }

        var dragVector = _dragPosition - _basePosition;
        float dragDistance = dragVector.magnitude;

        // ドラッグ入力ベクトルの向きを8方向に補正する
        float dragAngle = Vector2.Angle(Vector2.up, dragVector) * Mathf.Sign(-dragVector.x);
        dragAngle = CorrectAngle(dragAngle);
        dragVector = Quaternion.Euler(0f, 0f, dragAngle) * Vector2.up;

        // 入力量に応じて移動速度を変える
        var speedRate = Mathf.Clamp01(dragDistance / _dragMaxDistance);
        dragVector = dragVector.normalized * Mathf.Lerp(0f, _speed, speedRate);

        // カメラの現在角度を加味して、移動方向をX,Z軸基準に補正する（＝グリッドに沿わせる）
        var correctedCameraAngle = CorrectAngle(_camera.eulerAngles.y);
        var moveVector = Quaternion.Euler(0f, correctedCameraAngle, 0f) * new Vector3(dragVector.x, 0f, dragVector.y) * Time.deltaTime;
        transform.position += moveVector;

        // 移動方向へTrasformの向きを徐々に変える
        var eulerAngles = transform.eulerAngles;
        var moveAngles = Quaternion.LookRotation(moveVector).eulerAngles;
        var angleDiff = Mathf.DeltaAngle(eulerAngles.y, moveAngles.y);
        var angularVelocity = Mathf.Lerp(0f, _angularVelocity, Mathf.Clamp01(Mathf.Abs(angleDiff) / 90f));
        eulerAngles.y += Mathf.Min(angularVelocity * Time.deltaTime, Mathf.Abs(angleDiff)) * Mathf.Sign(angleDiff);
        transform.eulerAngles = eulerAngles;
    }

    /// <summary>
    /// 渡された角度を指定の分割数で補正
    /// </summary>
    private float CorrectAngle(float dragAngle, int sepCount = 8)
	{
        var sepAngle = 360f / sepCount;
        var sepAngleHalf = sepAngle / 2f;
        var ret = Mathf.Floor((Mathf.Abs(dragAngle) + sepAngleHalf) / sepAngle) * sepAngle * Mathf.Sign(dragAngle);
        return ret;
    }
}
```
※`DragHandler`は [この記事](https://qiita.com/flankids/items/0285577d278c88eaba87) で書いたような、I○○○Handlerを継承したタッチ領域と各種イベントを扱うクラスです

このコードで重要なのは、`Update`で行っている２つの角度補正です。

1つは `ドラッグ入力ベクトルの向きを8方向に補正する` とコメントしてる部分。
`CorrectAngle` メソッドを使って入力角度を 0°、45°、90°、135°、180°、225°、270°、315° のいずれかの角度の最も近いものに補正しています。

もう1つは、`カメラの現在角度を加味して、移動方向をX,Z軸基準に補正する（＝グリッドに沿わせる）` とコメントしてる部分。
カメラのY角度を `CorrectAngle` メソッドを使って補正し、それを補正済みのドラッグ入力ベクトルと加算しています。
この工程により、カメラの向きに対して直感的な入力でありつつ、X,Z軸に沿ったものになった移動ベクトルを計算しています。

# 小さい入力時は360度操作を解禁
一方で、Wonderboxでは細かな角度調整が必要なシチュエーションを想定して**操作量が小さい時は八方向補正を無効**にしているようです。

```Player.cs
private void UpdateMove()
{
    //---省略---//
    if (dragDistance >= _dragMaxDistance - 10) // 操作量が一定以上のときのみ補正を有効にする
    {
        // ドラッグ入力ベクトルの向きを8方向に補正する
        float dragAngle = Vector2.Angle(Vector2.up, dragVector) * Mathf.Sign(-dragVector.x);
        dragAngle = CorrectAngle(dragAngle);
        dragVector = Quaternion.Euler(0f, 0f, dragAngle) * Vector2.up;
    }
    //---省略---//
}
```

具体的には、`ドラッグ入力ベクトルの長さが一定未満のときに八方向補正を働かせない` という実装です。
「微妙な角度調整をしたいときはあまり大きな移動はせず、小さい入力量で行う」というユーザーがよくやる操作にフィットした仕組みで、やりたいときにやりたいことが実行されるために配慮された仕様だと感じました。

https://twitter.com/flankids/status/1405172144779137029

また、入力量が小さい時はUIが大きく展開され、操作方向がわかりやすいようになっています。
逆に入力量が大きい時は操作方向が補正されるのであまり意識する必要がなく、その分画面が見えやすいように小さく収まります。
このあたりもユーザーにとても配慮されている部分で素敵。

# カメラのY角度は45度の倍数に自動補正

Wonderboxでは、ドラッグ操作時はカメラ角度を自由に動かせますが、手を離した後は（大体）45度の倍数の角度に補正されるように動くようです。

https://twitter.com/flankids/status/1405172557104390144

これは、移動操作が八方向に補正される際、`スクリーン上の操作方向と補正結果の移動方向が大きくズレることを避ける`ための仕組みだと考えています。

![スクリーンショット 2021-06-16 14.22.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/765e8c34-a6e4-fd42-6cca-1b4b6e1fa9c2.png)

例えば上図は、カメラのY角度を45度の倍数にする補正をかけず、30度になっている状態で上方向に入力した時のものです。
この時、キャラクターの移動方向はグリッドに沿ってZ方向に直進していますが、`UI上の入力とキャラの向きに30度の乖離`があります。
そのためユーザーによっては「真上に行きたいのに斜めに動いちゃう」と感じる人がいるかもしれません。

カメラ向きと八方向の操作補正の噛み合わせが微妙な時、このようにユーザーとシステムの向きの解釈がズレてしまう可能性があります。
カメラ操作のドラッグを終えたとき、最寄りの45の倍数のY角度にカメラの向きを自動的に合わせてあげることで、そういうすれ違いを避けることができます。

### 実装

```Player.cs
/// <summary> I○○○Handlerを継承したタッチ領域（カメラ操作） </summary>
[SerializeField]
private DragHandler _cameraDragHandler;

/// <summary> カメラ制御 </summary>
[SerializeField]
private CameraController _cameraCtr;

/// <summary> カメラ1°回転に必要な操作量(pixel) </summary>
[SerializeField]
private int _pixelPerCameraRoll = 10;

/// <summary> 前回のカメラ回転ドラッグ操作座標 </summary>
private Vector2 _preDragPositionCamera;

/// <summary>
/// 起動時
/// </summary>
private void Awake()
{
    // カメラ操作初期化
    _cameraDragHandler.OnBeginDragEvent = OnBeginDragCamera;
    _cameraDragHandler.OnDragEvent = OnDragCamera;
    _cameraDragHandler.OnEndDragEvent = OnEndDragCamera;
}

/// <summary>
/// カメラ操作領域タッチ開始時
/// </summary>
private void OnBeginDragCamera(PointerEventData e)
{
    _preDragPositionCamera = GetLocalPointOnCanvas(e.position);
}

/// <summary>
/// カメラ操作領域タッチ時
/// </summary>
private void OnDragCamera(PointerEventData e)
{
    var dragPosition = GetLocalPointOnCanvas(e.position);
    var dragVector = dragPosition - _preDragPositionCamera;
    var deltaRotate = new Vector2(-dragVector.y, dragVector.x) / _pixelPerCameraRoll;
    _cameraCtr.SetRotate(deltaRotate);
    _preDragPositionCamera = dragPosition;
}

/// <summary>
/// カメラ操作領域タッチ終了時
/// </summary>
private void OnEndDragCamera(PointerEventData e)
{
    var correctedCameraAngle = CorrectAngle(_cameraCtr.transform.localEulerAngles.y);
    // カメラ角度を8方向に補正
    _cameraCtr.AlignAngleY(correctedCameraAngle);
}
```

`OnDragCamera`での操作時は入力に対して素直にカメラへ回転を与え、`OnEndDragCamera`で操作を終えた時に、`CorrectAngle`メソッドでカメラの角度を45度の倍数にして補正処理を呼び出しています。

```CameraController.cs
using UnityEngine;
using DG.Tweening;

public class CameraController : MonoBehaviour
{
    /// <summary> 最小X角度 </summary>
    [SerializeField]
    private int minAngleX = 0;

    /// <summary> 最大X角度 </summary>
    [SerializeField]
    private int maxAngleX = 55;

    /// <summary> 角度自動補正シーケンス </summary>
    private Sequence _seqRoll;

    /// <summary>
    /// 角度を指定した分だけ回転させる
    /// </summary>
    /// <param name="delta">変化量</param>
    public void SetRotate(Vector2 delta)
    {
        // 自動補正シーケンスが働いていたら停止
        _seqRoll?.Kill();

        var localEulerAngles = transform.localEulerAngles + (Vector3)delta;
        // パラメータの範囲以上に傾かないように補正
        localEulerAngles.x = Mathf.Clamp(localEulerAngles.x, minAngleX, maxAngleX);
        transform.localEulerAngles = localEulerAngles;
    }

    /// <summary>
    /// 指定したY角度まで自動的に回転
    /// </summary>
    /// <param name="duration">回転にかける時間</param>
    /// <param name="ease">イージングタイプ</param>
    public void AlignAngleY(float targetAngleY, float duration = 0.5f, Ease ease = Ease.OutCubic)
    {
        var targetAngles = transform.localEulerAngles;
        targetAngles.y = targetAngleY;
        _seqRoll?.Kill();
        _seqRoll = DOTween.Sequence();
        _seqRoll.Append(transform.DOLocalRotate(targetAngles, duration).SetEase(ease));
        _seqRoll.Play();
    }
}
```

`CameraController.cs`はカメラ制御スクリプトです。
子にカメラを持たせたオブジェクトに付与するコンポーネントで、

* 注視オブジェクトを座標で追いかける
* 子のカメラのローカルZ座標で撮影距離を作る
* 本体のX,Y角度で縦横の回り込み角度を作る

という仕組みのものです（記事内ではオブジェクトを追いかける処理を省いてます。興味がある方はGithubを参照していただけると嬉しいです）

https://twitter.com/flankids/status/1405172858574151684

こんな感じで、Transformを動かすだけでカメラワークを直感的に制御できます。

スクリプトのポイントは`AlignAngleY`メソッドで、`Player.cs`でドラッグ操作終了時に45度の倍数に補正されたカメラ角度を渡し、0.5秒かけて補正角度まで自動的に回転するようにしています。

# まとめ

ゲームがリッチになってきて自由度が増すほど、情報や選択肢の取捨選択が重要になるということがわかる各種仕様でした。

これらの仕様はマップチップで構成されるステージにフィットしたものなので、難易度作りをする上で「45度と30度を選ばせたい」という意図があるゲームではかえって邪魔になりうるものではありますが、逆に**この操作仕様に合わせてステージを設計すると難易度設計がやりやすい**ということも考えられるので、そういう意味での優れた仕組みだなと感じました。

`スーパーマリオ3Dランド`シリーズでは、まさに縦・横・斜めのグリッドに沿ったステージ作りになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/3208cd71-b414-2358-e3ab-0e0c65de1d39.png)

何気なく遊んでいるゲームも注意深く挙動を見ていると、ユーザーが遊びやすいような細かな配慮や、ストレスを減らすための工夫に満ちているなぁと改めて感じます。
「優れたUIほど印象に残らない」というような逸話も聞いたことがある気がするので、これからもこういう部分にアンテナを貼ってゲームやサービスに触れたいです。