キーボード、マウス、UI を両方で操作したい(8)

UI (場合によってはゲームも)をデザインする上で、最初に考えておくべきなのは(将来的に)使用されるコントローラの事です。

  • キーボード
  • マウス(スマフォだと≒タッチ操作)
  • ゲームパッド

これらのどこまで対応するかを考えて UI を設計します。
後から付け加えるというのは非常に難しく、例えばキーとマウスのどちらも使って操作した場合、カーソルがあらぬ場所にすっとんでしまう、といった不具合も非常に起こりやすいです。

UI/Button を使い、EventSystem の出来る範囲で操作を管理するというやり方が一見スクリプトも触ることなくスマートに思えますが、

  • デザインを思うように出来ない
  • かゆいところに手が届かない or 手を届かせる方法が大変

例えばこのようなメニューを UI/Button と EventSystem だけ使って、ノーコードで作る自信が私にはありません…。
そんなわけで、私はいつも UI/Image と Text を複数ぶら下げた Prefab でボタンを作っています。

今回作ったボタン

横並びなら楽なのに、面倒なボタン配置(トライアングル)にしてしまいました(笑

キーボード

左右を押すと「1人」「2人」をいったりきたり、下を押すと「CREDIT」、「CREDIT」で上を押すと「1人」に移動します。

「CREDIT」で上を押すと前回と同じ場所に行く、という移動も自然ですが、それをコーディングするのが面倒今回の場合はそれほど重要性がないので、簡単にしています。

考えてみるとそこまで面倒ではないんですが、つい横着を…。

マウス

マウスで面倒なのはマウスカーソルはポジショニングしてるけど、まだ決定していない(左クリックしていない)という状態があることです。
Windows10 のエクスプローラなどで確認するとわかりますが、マウスカーソルをあてても選択状態にはなりません。

薄い部分がマウスカーソルで選択している箇所

「ポジショニングしたらキーのように選んだことにすればいい」というインターフェイスも当然考えられるのですが、

「キーで操作していたら、誤って触れてしまったマウスのせいで誤作動」
「それに気づかず、不具合だと思った」

といったケースがちょくちょくあるので、あまり選択されないと思います。
今回のボタンではポジショニングした際、メニューの下に▲マークをつけるだけとし、クリックしない限り直接選択しないようにしています。

選択されてはいないけど、ポジションを示す▲が表示される

ポジショニングされてもボタンが押されるまでなんのアクションもしない、というのが1番簡単ですが、私はなんらかのアクションが欲しい派です。

タッチ

タッチ操作は、マウス操作をそのまま流用できることが多いです。
マウスのようなポジショニングはないので、▲表示はされません。

あと、スマフォゲームでちょくちょく見かけるのが、タッチするとゲームメニューに関係なくタッチエフェクトが表示される、という仕様です。
これはおそらく「そのタッチ操作は検知したよ」とゲームがプレイヤーに伝えるためだと思っています。

エフェクトだけ表示され、メニューなどが反応しないこともあります😢

一昔前の性能の悪いスマフォでハイエンドなゲームをプレイすると、タッチの検知自体も怪しくなるかもしれませんし、タッチ=無条件にエフェクトが出るとすることで、仮にエフェクトがでなければ「スマフォ(スペック)の問題で、ゲームができる状態じゃない」というのがわかりやすいから…なんですかね?

この表現は後でも追加しやすいですし、今回は実装しない方向でいきます。
気が向いたらつけましょう。

パッド

今回、パッド操作は見送りました。
なぜかというと、PC の場合「どんなパッドを使われるか不明」で、パッド対応するからには、パッドコンフィグは欲しいからです。
unity の純正ランチャーにパッドコンフィグはついていますが、いまいち微妙ですし、この規模のゲームでパッドコンフィグをつけるのも大げさですし…。

そもそもランチャー自体、ゲームプレイヤーには嫌われる傾向にあります。

今回のゲームはキーボードでも簡単にプレイできる内容なので、やめておく事にしました。
シューティングやアクション等、キーボードに不向きなゲームの場合は対応必須となるでしょう。

ボタンの作成

ボタンの prefab を作成します。
「※接触判定」は白く薄い四角形の部分で、その表示範囲分、ボタンの検出範囲を広げます。
詳しくは別記事をご覧ください。

白い半透明部分は Collision

GameObject にくっつけてる画像やテキスト
オレンジは UI/Image のイメージ画像

GameObject: 1Player が全ての動きを統括する

GameButton のスクリプトコード

using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class GameButton : MonoBehaviour, IPointerDownHandler, IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField]
    Image           ImgFrame    = null;
    [SerializeField]
    Image           MouseHover  = null;
    [SerializeField]
    Image           ImgSelected = null;
    [SerializeField]
    TextMeshProUGUI TxtDesc     = null;

    Color           camColor;

    Action<object>  clickEvent;
    object          clickEventTag;

    /// <summary>
    /// awake
    /// </summary>
    void Awake()
    {
        camColor = Camera.main.backgroundColor;

        dispExitMouseHover();
        dispUnSelected();

        if (Enum.TryParse(TxtDesc.text, out eLang lang) == true)
        {
            TxtDesc.SetText(Language.Get(lang));
        }
    }

    /// <summary>
    /// on disable
    /// </summary>
    void OnDisable()
    {
        dispExitMouseHover();
        dispUnSelected();
    }

    public void SetClickEvent(Action<object> _clickEvent, object _tag)
    {
        clickEvent    = _clickEvent;
        clickEventTag = _tag;
    }

    public void Select()
    {
        dispSelected();
    }

    public void UnSelect()
    {
        dispUnSelected();
    }

    void dispUnSelected()
    {
        ImgFrame.color = new Color(0, 0, 0, 0.5f);
        ImgSelected.color = new Color(0, 0, 0, 0);
        TxtDesc.color  = new Color(0, 0, 0, 0.5f);
    }

    void dispSelected()
    {
        ImgFrame.color = new Color(0, 0, 0, 1);
        ImgSelected.color = new Color(0, 0, 0, 1);
        TxtDesc.color  = new Color(camColor.r, camColor.g, camColor.b, 1);
    }

    void dispEnterMouseHover()
    {
        MouseHover.color = new Color(0, 0, 0, 1);
    }

    void dispExitMouseHover()
    {
        MouseHover.color = new Color(0, 0, 0, 0);
    }

    void IPointerEnterHandler.OnPointerEnter(PointerEventData eventData)
    {
        dispEnterMouseHover();
    }
    
    void IPointerExitHandler.OnPointerExit(PointerEventData eventData)
    {
        dispExitMouseHover();
    }

    void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
    {
        clickEvent?.Invoke(clickEventTag);
    }
}

スクリプトにマウス検知のインターフェイスを設定します。
インターフェイスは他にもありますが、今回使用したのは次の3つで大体事足ります。

IPointerDownHandler .. マウスクリックされた
IPointerEnterHandler .. マウスがポジショニングされた
IPointerExitHandler .. マウスがポジションから離れた

このイベント発生時に Image をオンオフしたり、文字の色を変える(演出する)ことでボタンを表現しています。

マウスイベント

    void IPointerEnterHandler.OnPointerEnter(PointerEventData eventData)
    {
        dispEnterMouseHover();
    }
    
    void IPointerExitHandler.OnPointerExit(PointerEventData eventData)
    {
        dispExitMouseHover();
    }

    void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
    {
        // 選択されたボタンのクリック内容を通知
        clickEvent?.Invoke(clickEventTag);
    }

演出

// 非選択
void dispUnSelected()
{
    ImgFrame.color = new Color(0, 0, 0, 0.5f);
    ImgSelected.color = new Color(0, 0, 0, 0);
    TxtDesc.color  = new Color(0, 0, 0, 0.5f);
}

// 選択
void dispSelected()
{
    ImgFrame.color = new Color(0, 0, 0, 1);
    ImgSelected.color = new Color(0, 0, 0, 1);
    TxtDesc.color  = new Color(camColor.r, camColor.g, camColor.b, 1);
}

// ▲表示
void dispEnterMouseHover()
{
    MouseHover.color = new Color(0, 0, 0, 1);
}

// ▲非表示
void dispExitMouseHover()
{
    MouseHover.color = new Color(0, 0, 0, 0);
}

イベントと演出メソッドが分かれているのは、この後キーボードでも流用するためです。

キーボード入力受け付け

マウスと異なり、勝手に適切なイベントが来るわけではないので、ここはゴリゴリとコードを記述していきます。
勢いで書いているコードですね…(後でまとめようとして忘れていました💦)

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WatchDemo : MonoBehaviour
{
    public enum eSelect
    {
        None,
        Player1,
        Player2,
        Credit,
    };

    [SerializeField]
    GameButton      Player1 = null;
    [SerializeField]
    GameButton      Player2 = null;
    [SerializeField]
    GameButton      Credit  = null;

    Action          clickStart = null;
    eSelect         select     = eSelect.None;

    void Start()
    {
        Player1.SetClickEvent(click1P, null);
        Player2.SetClickEvent(click2P, null);
        Credit.SetClickEvent(clickCredit, null);
    }

    /// <summary>
    /// Start クリック時のイベントを登録
    /// </summary>
    public void SetClickStart(Action _clickStart)
    {
        clickStart = _clickStart;
    }

    /// <summary>
    /// 選択キーを押した
    /// </summary>
    public bool PushEnter()
    {
        if (select == eSelect.None)
        {
            return false;
        }
        
        // 選択を決定
        clickStart?.Invoke();

        return true;
    }

    /// <summary>
    /// Credit 選択した?
    /// </summary>
    /// <returns></returns>
    public eSelect CheckSelectItem()
    {
        return select;
    }

    /// <summary>
    /// 1P 選択
    /// </summary>
    /// <param name="sound">true..サウンドを鳴らす</param>
    public void Select1P(bool sound)
    {
        if (select == eSelect.Player1)
        {
            return;
        }

        // Player1 選択

        select = eSelect.Player1;
    }

    /// <summary>
    /// 2P 選択
    /// </summary>
    /// <param name="sound">true..サウンドを鳴らす</param>
    public void Select2P(bool sound)
    {
        if (select == eSelect.Player2)
        {
            return;
        }

        // Player2 選択

        select = eSelect.Player2;
    }

    /// <summary>
    /// Credit 選択
    /// </summary>
    /// <param name="sound">true..サウンドを鳴らす</param>
    public void SelectCredit(bool sound)
    {
        if (select == eSelect.Credit)
        {
            return;
        }
        
        // Credit 選択

        select = eSelect.Credit;
    }

    /// <summary>
    /// 1P ボタンクリック
    /// </summary>
    void click1P(object dummy)
    {
        Select1P(true);
        PushEnter();
    }

    /// <summary>
    /// 2P ボタンクリック
    /// </summary>
    void click2P(object dummy)
    {
        Select2P(true);
        PushEnter();
    }

    /// <summary>
    /// Credit ボタンクリック
    /// </summary>
    void clickCredit(object dummy)
    {
        SelectCredit(true);
        PushEnter();
    }
}

シーンマネージャ

キー入力受け付けと一つにしてもよかったのですが、シーン内シーンをいくつか(デモ、プレイヤー選択、ステージ選択、CREDIT)もっている関係上、分けているほうが管理が楽でした。
このシーンマネージャはそれ単体では動かないので、参考程度にしてください。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// デモシーン
/// </summary>
public partial class SceneMenu : MonoBehaviour
{
    Coroutine co_watchDemoControl;

    /// <summary>
    /// init
    /// </summary>
    void init_watchDemo()
    {
        watchDemo.SetActive(true);
        watchDemo.Select1P(false);

        co_watchDemoControl = StartCoroutine(loop_watchDemoControl());
    }

    /// <summary>
    /// final
    /// </summary>
    void final_watchDemo()
    {
        if (co_watchDemoControl != null)
        {
            StopCoroutine(co_watchDemoControl);
        }

        watchDemo.SetActive(false);

        WatchDemo.eSelect item = watchDemo.CheckSelectItem();
        Debug.Log(item);
    }

    /// <summary>
    /// マウスでクリックしたり、ENTER押した時に発生するイベント
    /// </summary>
    void clickStart_watchDemo()
    {
        StartCoroutine(fadeout_watchDemo());
    }

    /// <summary>
    /// デモシーン中に入力があれば、ゲームへ
    /// </summary>
    IEnumerator loop_watchDemoControl()
    {
        watchDemo.SetClickStart(clickStart_watchDemo);

        while(true)
        {
            if (Input.GetKeyDown(KeyCode.LeftArrow) == true)
            {
                watchDemo.Select1P(true);
            }
            if (Input.GetKeyDown(KeyCode.RightArrow) == true)
            {
                watchDemo.Select2P(true);
            }
            if (Input.GetKeyDown(KeyCode.UpArrow) == true)
            {
                watchDemo.Select1P(true);
            }
            if (Input.GetKeyDown(KeyCode.DownArrow) == true)
            {
                watchDemo.SelectCredit(true);
            }
            if (Input.GetKeyDown(KeyCode.Return) == true || Input.GetKeyDown(KeyCode.Space) == true)
            {
                if (watchDemo.PushEnter() == true)
                {
                    break;
                }
            }
            yield return null;
        }
    }
}

さいごに

最近は unity や UnrealEngine などで最初からマルチターゲット、マルチ言語対応(グローバル対応)を目指すケースが増えてきていると思います。

コントローラの対応は、プレイヤーが思ってるより難問ですよね。
特にマウスは自由度が高いので、そのオペレーションありきのゲームがいざコンシューマーゲーム機に移植されると、操作し辛くて泣けることもしばしば。

そういった事にならないよう、最初から計画立てて UI の設計を…正直、最初から計画立てても、大変なものは大変でした!😢
この程度のメニューでも…意外と…。
キーボードとマウスを併用した時、実装が不完全だと「選んでないのが選択された」なんてことが簡単に起こりえますのでご注意ください。

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA