UI フレームワーク

フォーカス記憶、ゲームパッドナビゲーション、モーダルポーズを備えた、メニュー・HUD・ダイアログのためのパネルスタック。

メニュー・HUD・ダイアログをパネルのスタックとして管理 · フォーカス記憶、ゲームパッドナビゲーション、モーダルポーズ · no-op 代替: NullPanelStack

CGS は、ゲームが表示するすべての画面 — メニュー、HUD、オーバーレイ、ダイアログ — を IPanelStack(パネルのスタック)で管理します。_ui.Push(panel) でパネルをプッシュするとアクティブな入力ターゲットになり、_ui.Pop() でポップするとフォーカスが下のパネルへ戻ります。メニューコードをスパゲッティに変えがちな配管処理は、スタックが引き受けます:

  • フォーカス記憶。 覆われたパネルはフォーカス状態(どのボタンがハイライトされていたか、スクロール位置)を保存し、再び現れたときに復元します。
  • 入力ルーティング。 プッシュのたびに入力コンテキストが切り替わるため、開いたメニューがゲームプレイのボタンを横取りすることはなく、その逆もありません。
  • モーダルポーズ。 モーダル指定のパネル(背後のゲームをブロックするパネル)は、UI 時間を流したままゲーム時間を凍結できます。ダイアログはアニメーションし、世界は停止します。
  • ゲームパッドとキーボードのナビゲーション。 矢印キーと D-pad でフォーカスを移動でき、長押しでキーリピートが働きます。

すべてのパネルはひとつの入力言語を共有します。Esc またはゲームパッドの B ボタンで戻り、矢印キーまたは D-pad でフォーカスを移動し、Enter または A ボタンで決定します。

実装はオプションの CommonGameSystem.UI アセンブリに含まれています。Unity の Input System パッケージと組み込みの uGUI パッケージが必要です。どちらかのパッケージが削除された場合、このアセンブリは自身をコンパイル対象から除外し、パネルスタックは安全な no-op になります — CGS の残りの部分は通常どおり起動します。

メニューがどのキーにもボタンにも反応しない? パネルスタックはナビゲーションを Unity の Input System 経由でルーティングするため、Input サービスと同じプロジェクト設定要件を引き継ぎます: Active Input HandlingInput System Package (New) または Both である必要があります。新規プロジェクトでは Input Manager (Old) のままなので、すべてのパネルが入力を無視します。Welcome ウィンドウ(Tools > Common Game System > Welcome)ならワンクリックで修正できます — FAQ を参照してください。

サービスの取得

[DefaultExecutionOrder(100)] を付けた MonoBehaviour の Awake() または Start() で解決します:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class UiController : MonoBehaviour
{
    private IPanelStack _ui;

    private void Awake()
    {
        _ui = ServiceLocator.Resolve<IPanelStack>();
    }
}

参照をキャッシュし、それに対してメソッドを呼び出します。Update() 内で解決してはいけません。

API リファレンス

スタックの照会

int Depth { get; }        // Current stack depth (0 when empty)
IPanel ActivePanel { get; } // Top panel, or null if the stack is empty

プッシュ・ポップ・ルートまでポップ(メインスレッド専用)

void Push(IPanel panel)

パネルを最上位に積みます。覆われるパネルには SaveFocus()、続いて OnSuspended() が呼ばれます。新しいパネルには OnPushed() が呼ばれ、初期フォーカスを受け取り、専用の入力コンテキストを獲得します。panelnull の場合は ArgumentNullException をスローし、パネルがすでにスタック上にあるか、スタックが最大深度に達している場合は警告をログに出して何もしません。

bool Pop()

最上位のパネルを取り除きます。そのパネルには OnPopped() が呼ばれ、入力コンテキストが破棄されます。現れたパネルには OnResumed()、続いて RestoreFocus() が呼ばれます。スタックが空なら false、成功なら true を返します。プッシュまたはポップの進行中に再度呼び出すとスローします。

void PopToRoot()

最下位のパネルを除くすべてをポップします。中間のパネルには OnPopped() のみが呼ばれます(OnSuspended() は呼ばれません)。最下位のパネルには OnResumed()、続いて RestoreFocus() が呼ばれます。深度が 1 以下の場合は何もしません。

入力のディスパッチ

入力パイプライン(通常は InputAction 上のハンドラー)が UI 入力をスタックへ転送します:

void DispatchAction(PanelAction action)

ConfirmBackAltPrimaryAltSecondary をアクティブなパネルへルーティングします。デフォルトでは Back は自動的にパネルをポップします。InterceptsBackAction = true のパネルには代わりに HandleBack() が呼ばれ、ポップをブロックできます(未保存の変更の確認プロンプトなど)。ConfirmAltPrimaryAltSecondary は常にパネルの OnAction() を呼びます。

void DispatchFocus(Vector2 dirVec)

D-pad、左スティック、矢印キーの入力をアクティブなパネルの OnFocusRequested() へルーティングします。斜め入力は優勢な軸にスナップされ(左上は上になります)、ほぼゼロのベクトルは無視されます。

パネルが実装するもの(IPanel)

画面は IPanel を実装します。以下のメンバーはスタックが呼び出すものであり、自分で呼ぶことはありません:

メンバー呼ばれるタイミング
OnPushed()Push の直後。パネルはアクティブになっています。自身を表示し、リスナーを登録します。
OnSuspended()別のパネルが上にプッシュされたとき。一時的な状態を保存し、必要なら非表示にします。
OnResumed()上のパネルがポップされ、このパネルが再びアクティブになったとき。
OnPopped()このパネルがスタックからポップされたとき。リスナーを解除し、自身を非表示にします。
GetInitialFocus()Push 中、OnPushed() の後。最初にフォーカスすべき要素(通常は先頭のボタン)または null を返します。
OnFocusRequested(FocusDirection dir)矢印キーまたは D-pad の入力時(Up/Down/Left/Right)。ラップアラウンドは実装側の責任です。
SaveFocus()覆われるパネルに対して、OnSuspended() の直前。フォーカス状態を収めた FocusSnapshot 構造体を返します。
RestoreFocus(FocusSnapshot snapshot)現れたパネルに対して、OnResumed() の直後。
OnAction(PanelAction action)Confirm/AltPrimary/AltSecondary 入力用。Back は代わりに HandleBack() を経由します。
HandleBack()InterceptsBackActiontrue のときのみ。ポップをブロックするなら true、許可するなら false を返します。

また、以下のプロパティがパネルの性質を表します:

プロパティ意味
IsModaltrue の場合(かつ autoPauseOnModal オプションが有効な場合)、このパネルのプッシュで Gameplay クロックがポーズします。ゲーム時間は凍結し、UI と Background のクロックは流れ続けます。
InterceptsBackActionfalse(デフォルト)の場合、Back は自動的にパネルをポップします。true の場合、先に HandleBack() が実行されます。
AccessibleLabel将来のスクリーンリーダー対応のための予約。null でも構いません。
TransitionDurationこのパネルのフェード時間(秒)。null はスタックのデフォルトを使用します。

フォーカスターゲット(IFocusable)

void Focus()             // Move focus to this element (e.g. Selectable.Select())
bool IsFocused { get; }  // Whether this element currently holds focus

完全な例

uGUI のボタンで実装した設定パネルと、それを開くコントローラーの例です:

using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.UI;

// A small adapter that lets the panel stack focus a uGUI Selectable.
public sealed class ButtonFocusable : IFocusable
{
    private readonly Selectable _target;

    public ButtonFocusable(Selectable target)
    {
        _target = target;
    }

    public void Focus() => _target.Select();

    public bool IsFocused =>
        UnityEngine.EventSystems.EventSystem.current != null &&
        UnityEngine.EventSystems.EventSystem.current.currentSelectedGameObject == _target.gameObject;
}

[DefaultExecutionOrder(100)]
public class SettingsPanel : MonoBehaviour, IPanel
{
    [SerializeField] private Button _audioButton;
    [SerializeField] private Button _graphicsButton;
    [SerializeField] private Button _backButton;

    private IPanelStack _ui;
    private UnityEngine.Events.UnityAction _goBack;

    public bool IsModal => false;
    public bool InterceptsBackAction => false;
    public string AccessibleLabel => "Settings";
    public float? TransitionDuration => null;

    public void OnPushed()
    {
        _ui ??= ServiceLocator.Resolve<IPanelStack>();
        _goBack ??= () => _ui.Pop();
        _backButton.onClick.AddListener(_goBack); // register here...
        gameObject.SetActive(true);
    }

    public void OnPopped()
    {
        _backButton.onClick.RemoveListener(_goBack); // ...remove the SAME delegate here
        gameObject.SetActive(false);
    }

    public void OnSuspended() => gameObject.SetActive(false);

    public void OnResumed() => gameObject.SetActive(true);

    public IFocusable GetInitialFocus() => new ButtonFocusable(_audioButton);

    public void OnFocusRequested(FocusDirection dir)
    {
        // Move focus between buttons. Wrap-around is up to you.
        if (dir == FocusDirection.Down) _graphicsButton.Select();
        if (dir == FocusDirection.Up) _audioButton.Select();
    }

    public FocusSnapshot SaveFocus()
    {
        // FocusSnapshot is a plain struct — fill its fields directly.
        // Here we remember the focused Selectable by its instance id.
        var current = UnityEngine.EventSystems.EventSystem.current?.currentSelectedGameObject;
        return new FocusSnapshot
        {
            FocusedElementId = current != null ? current.GetInstanceID() : 0
        };
    }

    public void RestoreFocus(FocusSnapshot snapshot)
    {
        if (snapshot.FocusedElementId == _audioButton.gameObject.GetInstanceID()) _audioButton.Select();
        else _graphicsButton.Select();
    }

    public void OnAction(PanelAction action)
    {
        if (action == PanelAction.Confirm)
        {
            // Confirm pressed — the focused button receives the click.
        }
    }

    public bool HandleBack() => false; // Never called while InterceptsBackAction is false.
}

ゲーム内のどこからでもパネルを開くには:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class PauseMenuController : MonoBehaviour
{
    [SerializeField] private SettingsPanel _settingsPanel;

    private IPanelStack _ui;

    private void Awake() => _ui = ServiceLocator.Resolve<IPanelStack>();

    public void OpenSettings() => _ui.Push(_settingsPanel);
}

無効化する

ServiceLocator.Replace<IPanelStack>(new NullPanelStack());

これで UI フレームワークは完全に無効化されます。PushPop は静かな no-op になり、Depth は常に 0、ActivePanel は常に null を返します。入力コンテキストはプッシュされず、時間もポーズされず、イベントも発行されず、何もログに残りません。独自の UI システムを載せる場合に使ってください。

よくある落とし穴

  • メニューがすべての入力を無視する。 十中八九、原因はプロジェクト設定の Active Input Handling です — このページ冒頭のコールアウトと FAQ を参照してください。

  • メインスレッド専用。 すべての public メソッドとプロパティは、ワーカースレッドから呼ばれると InvalidOperationException をスローします。サービスを解決する MonoBehaviour には [DefaultExecutionOrder(100)] を付け、自動ブートストラッパーの登録が Awake() の前に完了するようにしてください。

  • 登録は OnPushed() で、解除は OnPopped() で。 ボタンリスナーはパネルのプッシュ時に追加し、ポップ時に同じデリゲートインスタンスを削除してください。削除を忘れると、次のプッシュで 2 つ目のハンドラーが追加され、クリックのたびに二重で発火します。Domain Reload を無効にしたエディターで最も痛い目に遭います。

  • モーダルポーズはカウント式。 モーダルのプッシュは Gameplay クロックをポーズし、対応するポップが再開します。Time サービスはポーズをカウントします — 2 回のポーズには 2 回の再開が必要です。自分でも Pause()/Resume() を呼ぶ場合は、呼び出しの対応を揃えないとゲームプレイが凍結したままになります。

  • 入力コンテキストはスタックと 1 対 1 で連動する。 プッシュのたびに入力コンテキストが追加され、ポップのたびに削除されます。autoPushInputContext オプションを無効にした場合は、入力コンテキストを自前で管理しないとメニュー入力がルーティングされません。

  • 独自の保存状態型は IL2CPP で link.xml が必要。 FocusSnapshot 自体はフレームワークが保持しているため、上の例に追加作業は不要です。パネルが独自のカスタム値型を保存状態に使う場合は、プロジェクトの link.xml に追加してください:

    <assembly fullname="YourGame">
      <type fullname="YourGame.MyCustomFocusState" preserve="all"/>
    </assembly>
    

関連ページ

  • Input — 入力コンテキスト、アクションマップ、リバインド
  • Time — モーダルポーズを支えるクロック
  • Event Bus — publish/subscribe イベント
  • FAQ — Active Input Handling の修正手順