学習記事一覧 · Unity本格入門

Unity本格入門:いきのこバトルで学ぶ UI とメニュー

題材・出典: 技術評論社刊『作って学べる Unity本格入門[Unity 6対応版]』(賀好 昭仁 著)に基づく学習補助の解説です。書籍の代替提供を目的とせず、コード掲載は学習上必要な範囲(必要最小限)にとどめます。利用条件は書籍記載(P.4〜5)および出版社サポート情報に従ってください。本シリーズ目次(書籍・著作の注記)

対象読者:第4回まで読み、メインシーンの戦闘まわりを把握したあと、画面上のメニュー・ダイアログ・ゲージを追いたい方
Menu.cs を軸に、ポーズアイテム/レシピ窓ライフゲージのつながりを読み解きます。

前提第3回:プレイヤーと新 Input SystemOwnedItemsData の保存形式は 第6回:所持品データとオーディオ で扱います(本記事では Instance の参照が中心です)。


記事の目次

ポイント一覧


この記事で扱う範囲

  • 主にMenu.csItemsDialog.csRecipeDialog.csLifeGauge.cs(抜粋)
  • 触れるだけItemButton.cs(きのこの使用は第6回で詳述)

ポーズ・インベントリ・レシピ・ライフゲージが重なる UI のイメージ

説明(学習のヒント)Time.timeScale でポーズしつつ、ESC/Cancel の入口から メニュー・アイテム・レシピを切り替えるイメージです。LifeGauge で体力表示を追う流れも含みます。


コードを全部見てみよう

Menu.cs(全文)

using UnityEngine;
using UnityEngine.UI;
public class Menu : SingletonMonoBehaviourInSceneBase<Menu>
{
    [SerializeField] private Button pauseButton;
    [SerializeField] private GameObject pausePanel;
    [SerializeField] private Button resumeButton;
    [SerializeField] private Text scoreText;
    [SerializeField] private Button itemsButton;
    [SerializeField] private Button recipeButton;
    [SerializeField] private Button throwAxeButton;
    [SerializeField] private Text throwAxeNumberText;
    [SerializeField] private GameObject throwAxeCursor;
    [SerializeField] private ItemsDialog itemsDialog;
    [SerializeField] private RecipeDialog recipeDialog;
    private void Start()
    {
        pausePanel.SetActive(false);
        pauseButton.onClick.AddListener(Pause);
        resumeButton.onClick.AddListener(Resume);
        itemsButton.onClick.AddListener(ToggleItemsDialog);
        recipeButton.onClick.AddListener(ToggleRecipeDialog);
        throwAxeButton.onClick.AddListener(() => { PlayerController.Instance.ToggleReadyToThrow(); });
    }
    private void Update()
    {
        scoreText.text = "" + MainSceneController.Instance.MinutesInGame;
        var throwAxeItem = OwnedItemsData.Instance.GetItem(Item.ItemType.ThrowAxe);
        var throwAxeNumber = throwAxeItem?.Number ?? 0;
        throwAxeNumberText.text = "" + throwAxeNumber;
        throwAxeButton.interactable = throwAxeNumber > 0;
        if (!Input.GetButtonDown("Cancel")) return;
        if (PlayerController.Instance.IsReadyToThrow)
        {
            PlayerController.Instance.IsReadyToThrow = false;
            return;
        }
        if (itemsDialog.gameObject.activeSelf)
        {
            itemsDialog.Toggle();
            return;
        }
        if (recipeDialog.gameObject.activeSelf)
        {
            recipeDialog.Toggle();
            return;
        }
        if (pausePanel.activeSelf)
        {
            Resume();
        }
    }
    public bool IsThrowAxeActive
    {
        set
        {
            throwAxeButton.image.color = value ? Color.red : Color.white;
            throwAxeCursor.SetActive(value);
        }
    }
    private void Pause()
    {
        Time.timeScale = 0;
        pausePanel.SetActive(true);
    }
    private void Resume()
    {
        Time.timeScale = 1;
        pausePanel.SetActive(false);
    }
    private void ToggleItemsDialog()
    {
        itemsDialog.Toggle();
    }
    private void ToggleRecipeDialog()
    {
        recipeDialog.Toggle();
    }
}

ItemsDialog.cs(全文)

using UnityEngine;
public class ItemsDialog : MonoBehaviour
{
    [SerializeField] private int buttonNumber = 15;
    [SerializeField] private Transform panel;
    [SerializeField] private ItemButton itemButton;
    private ItemButton[] _itemButtons;
    private void Start()
    {
        gameObject.SetActive(false);
        for (var i = 0; i < buttonNumber - 1; i++)
        {
            Instantiate(itemButton, panel);
        }
        _itemButtons = GetComponentsInChildren<ItemButton>();
    }
    public void Toggle()
    {
        gameObject.SetActive(!gameObject.activeSelf);
        if (!gameObject.activeSelf) return;
        for (var i = 0; i < buttonNumber; i++)
        {
            _itemButtons[i].OwnedItem = OwnedItemsData.Instance.OwnedItems.Length > i
                ? OwnedItemsData.Instance.OwnedItems[i]
                : null;
        }
    }
}

RecipeDialog.cs(全文)

using UnityEngine;
public class RecipeDialog : MonoBehaviour
{
    private void Start()
    {
        gameObject.SetActive(false);
    }
    public void Toggle()
    {
        gameObject.SetActive(!gameObject.activeSelf);
    }
}

LifeGauge.cs(全文)

using UnityEngine;
using UnityEngine.UI;
public class LifeGauge : MonoBehaviour
{
    [SerializeField] private Image fillImage;
    private RectTransform _parentRectTransform;
    private Camera _camera;
    private MobStatus _status;
    private void Update()
    {
        Refresh();
    }
    public void Initialize(RectTransform parentRectTransform, Camera targetCamera, MobStatus status)
    {
        _parentRectTransform = parentRectTransform;
        _camera = targetCamera;
        _status = status;
        Refresh();
    }
    private void Refresh()
    {
        fillImage.fillAmount = _status.Life / _status.LifeMax;
        var screenPoint = _camera.WorldToScreenPoint(_status.transform.position);
        RectTransformUtility.ScreenPointToLocalPointInRectangle(_parentRectTransform, screenPoint, null,
            out var localPoint);
        transform.localPosition = localPoint + new Vector2(0, 80);
    }
}

クラス関係(この記事で登場する範囲)

classDiagram direction TB class MonoBehaviour class SingletonMonoBehaviourInSceneBase class Menu class ItemsDialog class RecipeDialog class LifeGauge MonoBehaviour <|-- SingletonMonoBehaviourInSceneBase SingletonMonoBehaviourInSceneBase <|-- Menu MonoBehaviour <|-- ItemsDialog MonoBehaviour <|-- RecipeDialog MonoBehaviour <|-- LifeGauge Menu ..> ItemsDialog : "開閉" Menu ..> RecipeDialog : "開閉"

説明(学習のヒント)Menu だけがシーン内シングルトン基底を継承し、アイテム窓・レシピ窓は Menu から開閉を指示される関係です。


UMLで整理する(状態・シーケンス)

状態(ポーズとダイアログのイメージ)

stateDiagram-v2 [*] --> Playing Playing --> Paused Paused --> Playing Playing --> ItemOpen ItemOpen --> Playing Playing --> RecipeOpen RecipeOpen --> Playing

説明(学習のヒント):プレイ中・ポーズ・アイテム・レシピの「いまのモード」を丸で表し、矢印は切り替わりのイメージです(実装の if 分岐と対応)。

シーケンス(Cancel 処理の入口)

Update で Cancel を検知したあと、上から順に if で分岐します(先にマッチした処理だけが実行されます)。

sequenceDiagram participant Menu Menu->>Menu: "GetButtonDown Cancel" Note right of Menu: Cancel の優先は投擲からポーズ解除まで上から順

説明(学習のヒント):短い図ですが、Cancel を検知したあと Menu 内で上から順に どう振り分けるか、という入口だけを示しています。


ポイント①:Menu のシングルトンとボタン登録

MenuSingletonMonoBehaviourInSceneBase<Menu> です。Start で各 ButtononClick にリスナーを登録し、ポーズアイテムレシピ投擲モード切替を UI から呼び出します。投擲は PlayerController.Instance に直接触れています。


ポイント②:Time.timeScale でポーズと再開

Time.timeScale = 0;  // Pause
Time.timeScale = 1;  // Resume

timeScale が 0 のとき、ゲーム内時間に依存する処理(物理やアニメの多く)は止まります。ポーズ UI を出すときの定番です。再開し忘れるとずっと止まったままになるので、Resume の対になる操作を必ず用意します。


ポイント③:Update でスコア表示と投げオノ数を同期

MainSceneController.Instance.MinutesInGameText に表示し、OwnedItemsData から投げオノの個数を取ってボタンと連動させています。interactable で「0個なら押せない」UI にしています。


ポイント④:Legacy Input.GetButtonDown("Cancel") と ESC の処理順

このプロジェクトでは Legacy Input ManagerCancel ボタン(通常 ESC)で、

  1. 投擲モードならオフ
  2. アイテム窓が開いていれば閉じる
  3. レシピ窓が開いていれば閉じる
  4. ポーズ中なら再開

優先順位で処理しています。第3回の新 Input System(移動・攻撃)と併存している点に注意してください。


ポイント⑤:ItemsDialog とスロットの量産

プレハブ itemButtonbuttonNumber - 1Instantiate し、スロット数を確保しています。GetComponentsInChildren<ItemButton>() で配列化し、開いたときに OwnedItems を差し込みます。


ポイント⑥:ToggleOwnedItems のバインド

gameObject.SetActive(!gameObject.activeSelf);

表示に切り替わったタイミングだけ、OwnedItemsData.Instance.OwnedItems の要素を各 ItemButton に渡します。足りないスロットは null で「空き」を表現しています。


ポイント⑦:LifeGaugefillAmount・座標変換

  • Image.fillAmount で HP 比率を表示(教科書の HP ゲージと同じ考え方)
  • WorldToScreenPointScreenPointToLocalPointInRectangle で、3D キャラの頭上に UI を追従

Canvas が Screen Space - Overlay のとき、コメントのとおり第3引数に null を渡す書き方になっています。


ポイント⑧:RecipeDialog の開閉

レシピ側は Toggle で表示を反転するだけの最小実装です。中身のリスト表示などは別コンポーネントやプレハブ側の責務になります。


コードの流れを整理しよう

flowchart TD menu["Menu Update"] menu --> score["スコア・投げオノ数を表示"] menu --> esc{"Cancel?"} esc -->|"yes"| close["投擲OFF / ダイアログ閉じ / ポーズ解除"] btn["各ボタン onClick"] --> pause["Pause / Resume"] btn --> dlg["Items / Recipe Toggle"] btn --> throw["ToggleReadyToThrow"]

説明(学習のヒント)Update 側(上)とボタン経由(下)の2系統に分けています。Cancel のひし形は「押されたときだけ右の処理へ」というイメージです。


自分でカスタマイズしてみよう!

挑戦①:ポーズ中だけ別のテキストを出す

pausePanel 内に説明文を足し、Pause で表示を切り替えてみましょう。

挑戦②:スロット数

buttonNumber を変えると、Instantiate の回数と GetComponentsInChildren の結果が変わります。多すぎると負荷になるので、必要数に合わせましょう。


まとめ

  • Menu がシーン内シングルトンとして、ボタンスコア表示ESC 優先順位をまとめる
  • Time.timeScale でポーズし、ダイアログSetActive のトグルで開閉する
  • ItemsDialog はスロットを量産し、開いたときに OwnedItems をバインドする
  • LifeGaugefillAmount座標変換でキャラに追従する

所持データの保存形式と SE 再生は 第6回:所持品データとオーディオ へ続きます。


最終更新:2026年4月