5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

Wonderbox: The Adventure Maker

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

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

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

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

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

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

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

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

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

▲こうではなく・・・ ▲こういう感じ

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

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

実装

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この記事 で書いたような、I○○○Handlerを継承したタッチ領域と各種イベントを扱うクラスです

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

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;
    }
    //---省略---//
}

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

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

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

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

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

スクリーンショット 2021-06-16 14.22.58.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を参照していただけると嬉しいです)

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

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

まとめ

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

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

スーパーマリオ3Dランドシリーズでは、まさに縦・横・斜めのグリッドに沿ったステージ作りになっています。
image.png

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

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?