Unityを触り始めた頃って、つい何でもかんでも Update() に書きがちですよね。プレイヤー操作、UI更新、エフェクト制御、チュートリアル表示……全部が1つのスクリプトに詰め込まれていくと、だんだん「どこを直せばいいのか分からない巨大クラス」が出来上がってしまいます。

特に「操作ガイド」のようなUIは、ゲームロジックとは関係ないのに、ついプレイヤーのスクリプトにベタ書きしがちです。

  • 入力チェックとUI表示が密結合になる
  • シーンごとに操作ガイドがバラバラになりやすい
  • ボタンが増えるたびに、あちこちのコードを修正するハメになる

こういう時こそ、責務を分けた小さなコンポーネントにしておくと、あとからの改造がとても楽になります。この記事では「今この状況で押せるボタン(決定、キャンセル等)を画面隅に表示する」ためのコンポーネント TutorialOverlay を作ってみましょう。

【Unity】今押せるボタンだけ見せる操作ガイド!「TutorialOverlay」コンポーネント

ここで作る TutorialOverlay は、

  • 「決定」「キャンセル」「メニュー」などのアクション名と表示テキストを紐づけておく
  • ゲーム側は「今はこのアクションが有効だよ」と通知するだけ
  • 通知されたアクションだけを画面隅にリスト表示する

というシンプルな責務を持つコンポーネントです。入力そのもの(ボタンが押されたかどうか)は別のコンポーネントに任せ、ここでは「ガイドの表示」にだけ集中します。

フルコード:TutorialOverlay.cs


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

namespace TutorialUI
{
    /// <summary>
    /// 画面隅に「今押せるボタン」の操作ガイドを一覧表示するコンポーネント。
    /// 
    /// ・ゲーム側は RegisterAction / UnregisterAction を呼ぶだけ
    /// ・UI は Horizontal/Vertical Layout Group と Text/ Image でOK
    /// ・入力そのものの処理(ボタンが押されたかどうか)は別コンポーネントに任せる
    /// </summary>
    public class TutorialOverlay : MonoBehaviour
    {
        /// <summary>
        /// 1つの操作ガイド項目の定義。
        /// 例: ActionId = "Submit", label = "決定", icon = Aボタンの画像
        /// </summary>
        [Serializable]
        private class ActionDefinition
        {
            [Tooltip("アクションを一意に識別するID。ゲーム側からこのIDで有効化/無効化を呼び出します。")]
            public string actionId;

            [Tooltip("画面に表示するラベル。例: 決定 / キャンセル / メニュー")]
            public string label;

            [Tooltip("ボタンアイコン画像。無くてもOK(null可)。")]
            public Sprite icon;
        }

        [Header("UI 参照")]

        [SerializeField]
        [Tooltip("操作ガイド項目を並べる親オブジェクト。Vertical/Horizontal Layout Group を付けておくと便利です。")]
        private Transform itemsRoot;

        [SerializeField]
        [Tooltip("1つ分の操作ガイド UI プレハブ。Text と Image を含むプレハブを用意してください。")]
        private GameObject itemPrefab;

        [Header("定義")]

        [SerializeField]
        [Tooltip("利用する可能性のあるアクション一覧をここで定義します。")]
        private List<ActionDefinition> actionDefinitions = new List<ActionDefinition>();

        // 内部状態:アクションID → 定義
        private readonly Dictionary<string, ActionDefinition> _definitionLookup =
            new Dictionary<string, ActionDefinition>();

        // 内部状態:アクションID → 生成したUIアイテム
        private readonly Dictionary<string, GameObject> _activeItems =
            new Dictionary<string, GameObject>();

        // シングルトン的にどこからでもアクセスしたい場合に備えたオプション
        public static TutorialOverlay Instance { get; private set; }

        private void Awake()
        {
            // シングルトン(任意)。複数シーンに置く予定がなければそのままでもOK。
            if (Instance != null && Instance != this)
            {
                Debug.LogWarning("複数の TutorialOverlay が存在します。Instance は最後にロードされたものになります。");
            }
            Instance = this;

            // 定義を辞書に詰める
            _definitionLookup.Clear();
            foreach (var def in actionDefinitions)
            {
                if (string.IsNullOrEmpty(def.actionId))
                {
                    Debug.LogWarning("TutorialOverlay: actionId が空の定義があります。スキップします。", this);
                    continue;
                }

                if (_definitionLookup.ContainsKey(def.actionId))
                {
                    Debug.LogWarning($"TutorialOverlay: actionId '{def.actionId}' が重複しています。後から定義されたものを採用します。", this);
                }

                _definitionLookup[def.actionId] = def;
            }

            // シーン開始時点では全て非表示(アクティブな項目は0)
            ClearAll();
        }

        /// <summary>
        /// すべてのガイド表示を消す。
        /// シーン切り替えや状態リセット時に呼ぶと便利です。
        /// </summary>
        public void ClearAll()
        {
            foreach (var kvp in _activeItems)
            {
                if (kvp.Value != null)
                {
                    Destroy(kvp.Value);
                }
            }
            _activeItems.Clear();
        }

        /// <summary>
        /// 指定したアクションIDの操作ガイドを表示する(既に表示中なら何もしない)。
        /// 例: RegisterAction("Submit");
        /// </summary>
        public void RegisterAction(string actionId)
        {
            if (string.IsNullOrEmpty(actionId))
            {
                Debug.LogWarning("TutorialOverlay.RegisterAction: actionId が空です。", this);
                return;
            }

            // 既に表示中なら何もしない
            if (_activeItems.ContainsKey(actionId))
            {
                return;
            }

            // 定義が存在するか確認
            if (!_definitionLookup.TryGetValue(actionId, out var def))
            {
                Debug.LogWarning($"TutorialOverlay: 未定義の actionId '{actionId}' が指定されました。", this);
                return;
            }

            if (itemsRoot == null || itemPrefab == null)
            {
                Debug.LogError("TutorialOverlay: itemsRoot または itemPrefab が設定されていません。インスペクターを確認してください。", this);
                return;
            }

            // UI アイテムを生成
            var itemGO = Instantiate(itemPrefab, itemsRoot);
            itemGO.name = $"TutorialItem_{def.actionId}";

            // 子階層から Text と Image を探して設定する
            // Text は複数ある可能性もあるので、最初に見つかったものを使う
            var text = itemGO.GetComponentInChildren<Text>(true);
            if (text != null)
            {
                text.text = def.label;
            }

            var image = itemGO.GetComponentInChildren<Image>(true);
            if (image != null)
            {
                if (def.icon != null)
                {
                    image.sprite = def.icon;
                    image.enabled = true;
                }
                else
                {
                    // アイコン未設定なら Image をオフにしておく
                    image.enabled = false;
                }
            }

            _activeItems[actionId] = itemGO;
        }

        /// <summary>
        /// 指定したアクションIDの操作ガイドを非表示にする。
        /// 例: UnregisterAction("Submit");
        /// </summary>
        public void UnregisterAction(string actionId)
        {
            if (string.IsNullOrEmpty(actionId))
            {
                return;
            }

            if (_activeItems.TryGetValue(actionId, out var itemGO))
            {
                if (itemGO != null)
                {
                    Destroy(itemGO);
                }
                _activeItems.Remove(actionId);
            }
        }

        /// <summary>
        /// まとめてアクションIDを渡して、「これ以外は全部非表示」にするユーティリティ。
        /// 例: SetActiveActions(new[]{"Submit", "Cancel"});
        /// </summary>
        public void SetActiveActions(IEnumerable<string> actionIds)
        {
            if (actionIds == null)
            {
                ClearAll();
                return;
            }

            // 新しく有効にしたいIDのセットを作る
            var targetSet = new HashSet<string>(actionIds);

            // いらなくなったものを先に削除
            var toRemove = new List<string>();
            foreach (var kvp in _activeItems)
            {
                if (!targetSet.Contains(kvp.Key))
                {
                    toRemove.Add(kvp.Key);
                }
            }
            foreach (var id in toRemove)
            {
                UnregisterAction(id);
            }

            // 必要なものを追加
            foreach (var id in targetSet)
            {
                RegisterAction(id);
            }
        }
    }
}

使い方の手順

ここでは具体例として「プレイヤー操作ガイド」を右下に表示するケースを想定します。

手順①:UIプレハブを用意する

  1. Canvas を作成し、右下に操作ガイド用のパネル(例:Panel_TutorialOverlay)を配置します。
    • Anchor を右下に固定しておくとレイアウトが楽です。
  2. Panel_TutorialOverlay の子として空の GameObject(例:ItemsRoot)を作成し、
    Vertical Layout GroupHorizontal Layout Group を追加しておきます。
  3. 1つ分のガイドUIプレハブを作成します(例:TutorialItem_Prefab)。
    • Image(ボタンアイコン) + Text(ラベル)を並べたシンプルなもの。
    • このプレハブを itemPrefab に指定します。

手順②:TutorialOverlay コンポーネントをアタッチ

  1. Panel_TutorialOverlayTutorialOverlay コンポーネントを追加します。
  2. インスペクターで以下を設定します。
    • Items Root:先ほど作った ItemsRoot をドラッグ&ドロップ
    • Item PrefabTutorialItem_Prefab を指定
  3. Action Definitions に、使用するアクションを登録します。
    • actionId: "Submit", label: "決定", icon: Aボタンの画像
    • actionId: "Cancel", label: "キャンセル", icon: Bボタンの画像
    • actionId: "Menu", label: "メニュー", icon: Startボタンの画像

手順③:ゲーム側からアクションを有効化/無効化する

プレイヤーやゲーム状態を管理する別コンポーネントから、TutorialOverlay に「今有効なアクション」を教えてあげます。シングルトンの Instance を使うと、どこからでも簡単に呼べます。


using UnityEngine;
using TutorialUI;

public class PlayerStateTutorialBinder : MonoBehaviour
{
    // ゲームの状態によって有効な操作を切り替える例
    private enum PlayerState
    {
        Normal,
        Talking,
        MenuOpen
    }

    [SerializeField]
    private PlayerState currentState = PlayerState.Normal;

    private void Start()
    {
        UpdateTutorial();
    }

    private void Update()
    {
        // ここではデバッグ用にキーで状態を切り替える例
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            currentState = PlayerState.Normal;
            UpdateTutorial();
        }
        else if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            currentState = PlayerState.Talking;
            UpdateTutorial();
        }
        else if (Input.GetKeyDown(KeyCode.Alpha3))
        {
            currentState = PlayerState.MenuOpen;
            UpdateTutorial();
        }
    }

    private void UpdateTutorial()
    {
        if (TutorialOverlay.Instance == null)
        {
            Debug.LogWarning("TutorialOverlay.Instance が見つかりません。シーンに TutorialOverlay を配置しましたか?");
            return;
        }

        switch (currentState)
        {
            case PlayerState.Normal:
                // 通常時:移動・決定・メニュー
                TutorialOverlay.Instance.SetActiveActions(new[]
                {
                    "Submit",
                    "Menu"
                });
                break;

            case PlayerState.Talking:
                // 会話中:決定とキャンセルのみ
                TutorialOverlay.Instance.SetActiveActions(new[]
                {
                    "Submit",
                    "Cancel"
                });
                break;

            case PlayerState.MenuOpen:
                // メニュー中:キャンセルだけ
                TutorialOverlay.Instance.SetActiveActions(new[]
                {
                    "Cancel"
                });
                break;
        }
    }
}

このように、ゲーム側は「状態が変わったタイミングで SetActiveActions を呼ぶだけ」にしておくと、Update で毎フレームゴリゴリ書く必要がなくなります。

手順④:別シーンや別キャラでも使い回す

  • 敵キャラのチュートリアル(ロックオン・回避など)を表示したい場合も、同じ TutorialOverlay を参照して RegisterAction / UnregisterAction を呼ぶだけでOKです。
  • 動く床やギミックに「乗る」「調べる」などの一時的なアクションを出したい時も、そのトリガーから RegisterAction("Interact") を呼べば、共通の見た目でガイドを出せます。

メリットと応用

TutorialOverlay を導入すると、プレハブ管理やレベルデザインがかなり楽になります。

  • UIの見た目を一元管理できる
    ボタンアイコンやフォント、色などのデザインを変えたいときは、TutorialItem_Prefab を修正するだけで全シーンに反映されます。
  • ゲームロジックとUIが疎結合になる
    プレイヤーや敵のスクリプトは「今有効なアクションIDを教える」だけでよく、UIのレイアウトや表示内容にはノータッチで済みます。
  • レベルデザイン時に「ここでどんな操作をさせたいか」が明確になる
    ステージギミック側で RegisterAction("Jump") / UnregisterAction("Jump") を書くだけで、「この場面ではジャンプを教えたい」という意図がコードに現れます。
  • アクションの追加・名称変更が簡単
    新しい操作を追加したいときは、Action Definitions に1行追加するだけ。ラベル名の変更もここを変えれば全体に反映されます。

応用として、例えば「しばらく操作が変わらなければガイドをフェードアウトする」といった拡張も簡単に追加できます。

改造案:一定時間操作が変わらなければガイドを自動で隠す

以下は TutorialOverlay に追加するだけで使える、簡単な改造例です。


/*
 * TutorialOverlay クラス内に追加するフィールドとメソッドの例
 */

// 何秒間アクティブな操作が変化しなければ自動で非表示にするか
[SerializeField]
[Tooltip("この秒数アクション構成が変わらなければ、ガイドを自動的に消します。0以下なら無効。")]
private float autoHideSeconds = 0f;

private float _timeSinceLastChange = 0f;

private void Update()
{
    if (autoHideSeconds <= 0f)
        return;

    // アクティブな項目が1つもないときはタイマーをリセット
    if (_activeItems.Count == 0)
    {
        _timeSinceLastChange = 0f;
        return;
    }

    _timeSinceLastChange += Time.unscaledDeltaTime;

    if (_timeSinceLastChange >= autoHideSeconds)
    {
        ClearAll();
        _timeSinceLastChange = 0f;
    }
}

// RegisterAction / UnregisterAction / SetActiveActions の最後でこれを呼ぶ
private void NotifyActionsChanged()
{
    _timeSinceLastChange = 0f;
}

このように、操作ガイドを1つのコンポーネントに閉じ込めておけば、後から「自動フェード」「アニメーション」「ゲームパッド/キーボードでアイコン差し替え」などを足していくのも簡単です。巨大な God クラスにしないよう、小さな責務に分けてコンポーネント化していきましょう。