見出し画像

【Unity】推理ボードとマルチエンディングを実装する(ScriptableObject・C# event・PlayerPrefs・周回)


第1章 はじめに

主旨

Unity入門書は読み終えたけれど、その先の技術書がない!
そんな方のための「初級から中級への架け橋」を目指す連載記事です。
3Dアクションゲームを開発して行きます🎮

  1. ✅ [公開済み] 第1弾:『人型モデルを動かす(Blend Tree・CharacterController・Input Systemで作るTPS移動の基礎)』

  2. ✅ [公開済み] 第2弾:『バージョンアップ完全ガイド(移行手順・エラー対応・容量節約まで)』

  3. ✅ [公開済み] 第3弾:『ProBuilderで遺跡をブロックアウトする(中空トンネル・T字路・広間をメニュー操作だけで作る)』

  4. ✅ [公開済み] 第4弾:『遺跡に光と霧を灯す(暗所ライティング・ランタン・コルーチン・フォグ・環境音で作る探索の空気感)』

  5. ✅ [公開済み] 第5弾:『一人称視点と「調べる」を実装する(自作Input Actions・TPS⇄FPS切替・Raycastインタラクション)』

  6. ✅ [公開済み] 第6弾:『TextMeshProで日本語を表示する(フォントアセット作成・Localization多言語化・名前入力保存)』

  7. ✅ [公開済み] 第7弾:『データで仕掛けを量産する — ScriptableObjectカタログと抽選機(心臓部) 』

  8. ✅ [公開済み] 第8弾:『迷いの回廊を作る(ループ迷路・シームレステレポート・導きの精霊)』

  9. ✅ [公開済み] 第9弾:『番人ゴーレムを実装する(NavMesh巡回・視界判定・ランタンステルス)』

  10. ✅ [公開済み] 第10弾(前編):『タイトル画面とセーブを実装する(Build Profiles・フェード遷移・ポーズ・ビットフラグ保存)』
    ✅ [公開済み] 第10弾(後編):『AudioMixer・足音・3D定位・ミニマップ』
    ※ 第10弾は「前編」「後編」に分けます

  11. ✅ [公開済み] 第11弾(前編):『遺跡に演出を仕込む(コルーチン演出・強制歩行・パーティクル・仕掛けの扉)』
    🆕 【今回の記事】第11弾(後編):『推理ボードとマルチエンディングを実装する(ScriptableObject・C# event・PlayerPrefs・周回)』
    ※ 第11弾は「前編」「後編」に分けます

  12. 📅 [執筆予定] 第12弾:『見た目を仕上げる — 3Dアセットの探し方・ライセンス・組み込み』

  13. 📅 [執筆予定] 第13弾:『仕上げと公開 — 調整・ビルド・itch.io公開』

なお、筆者は入門書を読み終えたばかりの初心者です。
自身の備忘録も兼ね、調べながら書いておりますため、何卒、あたたかい目で見守っていただければと思います🐣

前編で、遺跡は動き出しました。導入の演出があり、宝箱のフタがゆっくり開き、列をやり過ごし、クリスタルを灯すと出口の扉が開きます。

ですが、扉の向こうにあるのは「探索完了」の1種類だけです。石碑の刻文も、手記のページも、読んで終わり。遺跡の中に「考えて、答えを出す」場所が、まだありません。

後編では、この遺跡に「結末」を与えます。

  • 石碑や手記から手がかりを集める。手がかりは第7弾と同じ ScriptableObject のカタログにする

  • 集めた手がかりは保存され、画面の隅の数が自動で増える。ここで C# の event を覚える

  • 宝物庫の奥に推理の板を置く。3つの空欄に手がかりをはめて、まとめて判定する

  • 出口で結末を選ぶ。秘宝の数で選べる道が変わり、推理を当てた人だけ隠しの結末を選べる

  • 2周目のタイトルに前回の結末が出て、入口に前回の探索者の名が刻まれる。「はじめから」でやり直せる

■ 道具は、ほとんど持っています
前編と同じで、新しく覚える機能はわずかです。ScriptableObject は第7弾で、PlayerPrefs は第6弾と第10弾前編で、UI の組み立ては第6弾から何度も作ってきました。今回新しいのは、C# の event(前編の UnityEvent の兄弟)と、翻訳を「取り寄せて待つ」書き方の2つだけです。

👩‍💻 こんな方に向けた内容です
・ScriptableObject を「見た目の差分」以外にも使ってみたい方
・C# の event と UnityEvent の使い分けを、動くもので理解したい方
・集めたものを PlayerPrefs に保存する方法(ビットフラグの次)を知りたい方
・穴埋め型の推理UIを、Layout Group を使わずに組んでみたい方
・マルチエンディングと周回(ニューゲーム+)の作り方に迷っている方
・第1〜11弾(前編)から続けて作ってくださっている方

🛠️ 動作検証環境
・macOS Tahoe 26.5.1
・Unity 6.5(6000.5.5f1)+URP 17.5.0
・Input System 1.20.0/Cinemachine 3.1.7/UniVRM 0.131.2(VRM0.x) ・ProBuilder 6.1.2/Unity Localization 1.5.12/AI Navigation 2.0.14
・エディタは日本語化しています(メニュー名は日本語表記+必要に応じて英語を併記)


今回の到達点

  • 手がかりが ScriptableObject のカタログになり、石碑は読むたびに続きが出る

  • 手がかりを集めると保存され、画面の隅の数と通知が自動で更新される(C# event)

  • 宝物庫の奥の「推理の板」で、3つの空欄に手がかりをはめて、まとめて判定できる

  • 出口で結末を選べる。秘宝が3つそろうと選択肢が増え、推理を当てた人だけ隠しの結末を選べる

  • 結末は3種類。選んだ瞬間、音がすっと引く(前編で作った「静けさ」の初使用)

  • 2周目のタイトルに前回の結末が出て、入口に前回の探索者の名が刻まれる

  • 「はじめから」で、名前と音量の設定だけを残して探索をやり直せる


Unity 6.6 と「プレイモードに入るときの設定」について

2026年9月1日に Unity 6.6 が公開され、6.5 は公式に「サポート終了」の扱いになりました。この連載は前編に続いて 6.5 のまま進めます。

ただし、6.6 で試す方には、後編に直接関わる変更が1つあります。 6.6 で新しく作ったプロジェクトは、既定で「プレイモードに入るときにドメインを再読み込みしない」設定になります。

すると、static の変数や static の event に登録した相手が、前回の再生から残ったままになります。設定の場所は「編集 ▸ プロジェクト設定 ▸ エディター」の Enter Play Mode Settings にある「When entering Play Mode」のドロップダウン(4択)で、6.5 の既定は「ドメインもシーンも読み直す(Reload Domain and Scene)」です。

後編は、static の event を使わない設計にしています。それでも UiGate.IsOpen と GameManager.LastEnding は static なので、6.6 の方は前編第2章の補足(UiGate にも再生時のリセットを足す)を先に済ませておいてください。

第6章では、この点を踏まえて「前回の結末」を static ではなく保存した値から読みます。


本記事の構成

後編は「データで持つ → 集める → UIで見せる → 結末を分ける → 次の周回に残す」の一本道です。前の章の部品を、次の章がそのまま使います。順番どおりに進めてください。

第2章「手がかりをデータで持つ」 第7弾の ScriptableObject の2回目の出番です。手がかり1件=アセット1個の台帳を作り、石碑を「読むたびに続きが出る」形にします。翻訳を「取り寄せて待つ」書き方(ブラウザ版の安全策)もここで。 第3章「集めて、保存して、知らせる」 収集帳を作ります。前編第7章で予告した C# の event が主役です。

「何も集めていないのに1個ある」という困りごとを先に踏んでから直し、「繋いだら、外す」という event の作法を覚えます。 第4章「推理ボードのUIを組む」 後編でいちばんUIの部品が多い章です。3つの空欄に、集めた語をはめて、まとめて判定します。1行を丁寧に作り、残りは複製します。 第5章「結末を分ける」 出口で結末を選ぶパネルを作り、結末を3種類にします。前編の「静けさ」をここで初めて使います。

第6章「次の周回に残す」 前回の結末をタイトルに出し、入口に記念碑を立て、「はじめから」を作ります。第10弾前編の LastEnding を、ついに読み出します。

おわりに Timeline とコルーチンの答え合わせとなります。


今回のコードの載せ方について

今回は、新しいスクリプトを8本作り、これまでに書いたスクリプトを6本改修します。

  • 新しいスクリプトは、これまでどおり全体を載せます(ClueData・ClueJournal・ClueCounterHUD・ClueNotifier・DeductionStand・DeductionBoard・EndingChoiceUI・LapMemorial)

  • 改修する6本(StoneMonument・ExplorerJournal・GameManager・EndingTrigger・EndingMenu・TitleMenu)は、すべて改修後の全体を載せます。追加・変更した行には // 💡 後編で… のコメントを付けました。丸ごと貼り替えて構いません

  • 前編と同じく、既存スクリプトを開くのは1本につき1回です。第2章で仕込む⏳予約席2つは、第3章でコメントを外すだけです

  • 改修するスクリプトは、ファイルを上書き保存するだけです。すでにオブジェクトに付いているコンポーネントに、新しい欄がそのまま増えます。「コンポーネントを追加」で付け直すと、同じスクリプトが2つ付いてしまうので、付け直さないでください

📝 手がかりの文言やボードのラベルは、学習効率を優先して直書きにします(第6弾の手記と同じ方針)。多言語化したい方向けの案内は、各章の腕試しに書きます。

それでは、始めましょう。


第2章 手がかりをデータで持つ(ClueData・石碑・手記)

主旨

推理には材料が要ります。プレイヤーが遺跡で拾い集める「手がかり」です。今回は、これを第7弾と同じ ScriptableObject(SO)で持ちます。

第7弾の台帳(MimicTellData)は「宝箱をどう化けさせるか」という見た目の差分でした。今回の台帳は「誰が」「何のために」「どこへ」の空欄にはまる言葉です。中身は違いますが、付き合い方は同じです。

  • SO は読み取り専用の台帳として扱う(実行中にスクリプトから書き込まない)

  • 実行中に変わる値(どれを集めたか)は MonoBehaviour 側に持つ(第3章の収集帳)

  • セーブしたい値は PlayerPrefs へ(第3章)

この章では、台帳を作り、手がかりが出てくる場所(石碑と手記)を整えるところまでです。「集める」の受け皿は第3章で作るので、渡す1行は⏳予約席にしておきます。


ClueData.cs(手がかりの台帳)

Projectウィンドウで Assets/Scripts を右クリック ▸ 作成(Create) ▸ Scripting ▸ ScriptableObject スクリプト(第7弾と同じテンプレートです)。名前を ClueData にして、中身を次のように書き換えます。

// ==========================================================
// 📄 スクリプト:ClueData.cs(ScriptableObject・新規)
// ==========================================================
using UnityEngine;

// 手がかりが「どの空欄」にはまる言葉かを表します。
public enum ClueSlot
{
    Who,    // 誰が
    Why,    // 何のために
    Where   // どこへ
}

// 「手がかり1件ぶんのデータ」を入れる台帳。手がかり1件=アセット1個。
[CreateAssetMenu(
    fileName = "Clue_New",          // 生成されるアセットの初期ファイル名
    menuName = "推理/手がかり",      // 作成メニューでの表示
    order = 0)]
public class ClueData : ScriptableObject
{
    [Header("識別(セーブに使う。一度決めたら変えない)")]
    public string id = "";                    // 例:who_master

    [Header("表示")]
    public string word = "(未設定)";        // ボードに出す言葉。例:知恵の師
    public ClueSlot slot = ClueSlot.Who;      // どの空欄にはまるか

    [Header("メモ(開発用。ゲームには出ません)")]
    [TextArea(1, 3)] public string note = "";

    // インスペクターで値を変えたとき、エディタが呼んでくれる「入力チェック係」。
    // ビルドしたゲームでは呼ばれません(エディタ専用の関数です)。
    private void OnValidate()
    {
        if (string.IsNullOrEmpty(id))
            Debug.LogWarning($"ClueData「{name}」: id が空です。セーブに使うので必ず入れてください", this);
        else if (id.Contains("|"))
            Debug.LogWarning($"ClueData「{name}」: id に「|」は使えません(第3章で保存の区切り文字に使います)", this);
    }
}

■ コードの読みどころ

  • id は、アセット名から作りません。 第10弾前編で、セーブに使う番号は一度決めたら変えられない、と書きました。同じ理屈で、手がかりを保存するときの名札は、アセットの名前ではなく、この id を使います。アセット名は整理のために気軽に変えたくなるものですが、変えた瞬間にセーブが壊れるのでは困ります。

  • OnValidate() は、エディタが呼ぶ入力チェック係です。 インスペクターで値を変えるたび(とスクリプトの読み込み時)に呼ばれ、id が空だったり、あとで区切り文字に使う「|」が混ざっていたりしたら、コンソールに警告を出します。ビルドしたゲームでは呼ばれません。「台帳の設定ミスは、入力した瞬間に教えてもらう」という保険です。

  • 第7弾と同じく、フィールドは public のままです。読まれるための台帳だからです。


アセットを8つ作る

コンパイルが通ったら、台帳のアセットを作ります。題材は「誰が・何のために・どこへ」の3つの空欄に対する、薄いサンプルです。読者の方は、自分の遺跡の物語に置き換えてください。 置き換えるのはアセットの中身だけで、スクリプトはこの先も1文字も変わりません。

Projectウィンドウで Assets/Data を右クリック ▸ 作成 ▸ フォルダで Clues を作り、その中で右クリック ▸ 作成 ▸ 推理 ▸ 手がかり を8回繰り返します。

⚠️ 表の「正解か」と「出てくる場所」の2列は、インスペクターには入れる欄がありません。この2列は設計のメモです。

  • 正解は、第4章の推理ボードの Answer Who/Answer Why/Answer Where で決めます(台帳に「正解」の印を持たせない理由は第4章で説明します)

  • 出てくる場所は、この章の後半で、石碑の Page Clues と手記の Grants にどのアセットを割り当てるかで決まります

アセットに入れるのは ID・Word・Slot の3つだけです。メモを残したい方は、Note 欄に「正解/入口の石碑・2頁目」のように書いておくと便利です(Note はゲームには出ません)。

例:Clue_WhereTown(idの警告は初期時に空白だったものがコンソールに残っているため)

📝 インスペクターの上にある「Addressable」のチェックは、外したままにしてください。Addressables は、アセットに住所(アドレス)を付けて、実行中に名前で呼び出したり、あとからダウンロードしたりする仕組みです。第6弾で入れた Localization パッケージが内部で使っているため一緒に導入され、それ以降、アセットのインスペクターに表示されるようになりました。この連載では、手がかりのアセットは石碑やボードから直接参照するので、住所は要りません。その下の「Added」「Check in」「Undo」は、Unity Version Control(UVCS)の表示です。

  • 正解の3つは、石碑の「続き」にだけ置いてあります。1回読んだだけでは正解が出そろわず、読み返す動機になります。誤答(推理をまぎらわせる語)は、手記にまとめて置きました。

  • word はひらがなと基本的な漢字にとどめてください。第6弾で焼いた日本語フォントは Static(焼き込んだ文字しか出せない)なので、凝った漢字は豆腐(□)になります。第7弾の銘板と同じ注意です。


石碑を「読むたびに続きが出る」形にする

いまの石碑は、何度調べても同じ刻文を見せます。これを、1回目・2回目・3回目で文が変わる形にします。そして、頁ごとに手がかりを渡せるようにします。

■ String Table に刻文の続きを足す
第6弾で作った文字列テーブル GameText に、3つのエントリを足します。

メニュー「ウィンドウ」→「アセット管理(Asset Management)」→「Localization Tables」を開き、GameTextを選んで、New Entry で追加します(第8弾で rule_inscription を足したときと同じ手順です)。

1頁目は、第6弾の monument_inscription と第8弾の rule_inscription をそのまま使います。文言も変えません。

■ 目玉つまずき:ブラウザ版で石碑を読むと、例外が出る
ここで、第6弾の StoneMonument に潜んでいた問題に触れておきます。

第6弾では、刻文をこう取り出していました。

// 第6弾の書き方(同期取得)
loreUI.Open(inscription.GetLocalizedString());

この GetLocalizedString() は「その場で文字列を返す」便利な書き方ですが、Unity公式のAPIリファレンスには、この関数が内部で読み込みの完了を待つ仕組み(WaitForCompletion)を使うこと、そしてその仕組みが WebGL ではサポートされないことが明記されています。エディタと Mac/Windows のビルドでは動くのに、ブラウザ版だけ、石碑を読んだ瞬間に例外で止まる可能性があります。

📝 文字列テーブルを先に読み込んでおく設定(Preload)もあります。ただ、公式フォーラムでは「Preload していても同期の取得で例外が出た」という報告に、Unityのスタッフが不具合として報告するよう求めています(2025年9月)。Preload は「初回の待ちを減らす設定」であって、同期取得を使ってよい理由にはならない、と考えておくのが安全です。

対策は「取り寄せて、届くまで待つ」書き方にすることです。GetLocalizedStringAsync() は「取り寄せの伝票(AsyncOperationHandle)」を返します。伝票は、コルーチンの中で yield return できます(第4弾のコルーチンの「待つ」が、ここでも効きます)。届いたら .Result に文字列が入っています。

この章で StoneMonument を開くのは、この1回だけです。「読むたびに続き」と「取り寄せ」を一緒に入れます。

■ StoneMonument.cs(改修後・全体)

// ==========================================================
// 📄 スクリプト:StoneMonument.cs(第11弾後編・改修後・全体)
// ==========================================================
using System.Collections;                              // 💡 後編で追加:コルーチンを使う
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.ResourceManagement.AsyncOperations; // 💡 後編で追加:AsyncOperationStatus を使う

public class StoneMonument : MonoBehaviour, IInteractable
{
    [SerializeField] private LocalizedString promptEntry; // 例:GameText/prompt_examine

    // 💡 後編で変更:刻文を1つから「頁の配列」に。読むたびに次の頁へ進みます
    [Header("刻文(読むたびに次の頁へ進む)")]
    [SerializeField] private LocalizedString[] pages;

    // 💡 後編で追加:各頁を読んだときに渡す手がかり(渡さない頁は None のまま)
    [Header("各頁で渡す手がかり(無い頁は None)")]
    [SerializeField] private ClueData[] pageClues;

    [SerializeField] private LoreUI loreUI;

    private string cachedPrompt = "調べる"; // 💡 後編で追加:翻訳が届くまでの仮の文言
    private int nextPage = 0;              // 💡 後編で追加:次に見せる頁
    private bool loading;                  // 💡 後編で追加:取り寄せ中の二重呼び出しを防ぐ

    // 💡 後編で変更:プロンプトは毎フレーム取りにいかず、「変わったら教えて」と登録しておく。
    //    登録した直後(読み込み済みなら)と、言語を切り替えたときに、届いた文字列が cachedPrompt に入ります。
    private void OnEnable()  { promptEntry.StringChanged += OnPromptChanged; }
    private void OnDisable() { promptEntry.StringChanged -= OnPromptChanged; }
    private void OnPromptChanged(string value) { cachedPrompt = value; }

    public string GetPrompt() => cachedPrompt;

    public void Interact()
    {
        if (loading) return;                              // 取り寄せ中は無視
        if (pages == null || pages.Length == 0) return;   // 頁が無ければ何もしない
        StartCoroutine(ShowPage());
    }

    // 💡 後編で追加:次の頁を「取り寄せて、届いたら開く」
    private IEnumerator ShowPage()
    {
        loading = true;
        int index = Mathf.Min(nextPage, pages.Length - 1); // 最後の頁で止まる

        // 取り寄せの伝票を受け取る。届いていなければ、届くまで待つ
        var handle = pages[index].GetLocalizedStringAsync();
        if (!handle.IsDone) yield return handle;

        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            loreUI.Open(handle.Result);   // 届いた文字列で、パネルを開く

            // この頁に手がかりがあれば、収集帳へ渡す
            if (pageClues != null && index < pageClues.Length && pageClues[index] != null)
            {
                // ⏳【第3章で有効化】収集帳(ClueJournal)は第3章で作ります。
                //    いま有効化すると、まだ存在しないクラスを参照して error CS0246 になります。
                // ClueJournal.Instance.Add(pageClues[index]);
            }

            if (nextPage < pages.Length - 1) nextPage++;   // 次に読むときは次の頁
        }
        else
        {
            Debug.LogWarning("StoneMonument: 刻文を取り出せませんでした(テーブルの割り当てを確認)", this);
        }

        loading = false;
    }
}

※ Assets/Scripts内の StoneMonument.cs を上書き保存します。

■ コードの読みどころ

  • GetLocalizedStringAsync() は伝票を返します。 if (!handle.IsDone) yield return handle; は「まだ届いていなければ、届くまで待つ」です。届いていれば待ちません(待つと1フレーム余計に遅れるからです)。届いたあと、handle.Status が成功なら handle.Result に文字列が入っています。

  • プロンプトは「取りにいく」のをやめて、「届いたら教えて」に変えました。 第6弾では GetPrompt() が呼ばれるたびに翻訳を取りに行っていました。Interactor は照準が合っている間、毎フレーム GetPrompt() を呼ぶので、毎フレーム取りに行っていたことになります。StringChanged に登録しておくと、最初の登録のときに読み込みが始まり、届いたときと、言語を切り替えたときに、勝手に呼んでくれます。届くまでは cachedPrompt の仮の文言「調べる」が出ます。

  • 登録と解除は、対にします。 OnEnable で +=、OnDisable で -=。この「対にする」は、第3章の event でも同じことを繰り返し言います。

  • nextPage は最後の頁で止まります。 3頁の石碑を4回調べると、4回目も3頁目です。「もう続きが無い」ことが分かります。

  • loading は二重呼び出しの保険です。 取り寄せの最中にもう一度 E を押されても、2枚目のパネルが開かないようにしています(実際には LoreUI が開くと UiGate が閉まるので起きにくいのですが、念のためです)。

🧩 ここで LoreUI は1文字も触っていません。Open(string) に「届いた文字列」を渡すだけで、開く側は取り寄せのことを知らずに済みます。第5弾の IInteractable と同じ「相手の事情を知らなくていい」作りです。

■ インスペクターの割り当て(2本の石碑)
ヒエラルキーで入口の広間の『 Monument 』を選択します。Stone Monument (スクリプト) の欄が変わっています。

  • Prompt Entry:GameText/prompt_examine のまま

  • Pages:Size を 3 にして、要素ごとに Table Collection を GameText、Entry を上から monument_inscription・monument_inscription_2・monument_inscription_3

  • Page Clues:Size を 3 にして、上から Clue_WhyTest・Clue_WhoMaster・Clue_WhereTown(Projectウィンドウからドラッグ)

  • Lore UI:LorePanel のまま

⚠️ 第6弾の割り当て手順に、誤りがありました。Prompt Entry の割り当て先を「GameText/monument_inscription」と書いていましたが、正しくは GameText/prompt_examine です。文字どおりに設定していると、プロンプトに刻文の全文が出ます。ここで直してください。お詫びします。

続いて、深層の通路の『 Monument_Rule 』を選択します。

  • Pages:Size 2。rule_inscription・rule_inscription_2

  • Page Clues:Size 2。Clue_WhySeal・Clue_WhereVault

📝 石碑の表面の3D TMP(MonumentInscription)は、第6弾の Localize String Event が1頁目の文言を出し続けます。表面に彫ってある文と、調べて読む全文が1頁目で一致していれば、続きの頁は「調べたときだけ読める」内容になります。設計としては、これで十分です。


手記にも手がかりを持たせる

手記(第6弾の ExplorerJournal)は、直書きの3ページのままです。読んだら手がかりを渡す口を足します。開くのは、この1回だけです。

■ ExplorerJournal.cs(改修後・全体)

// ==========================================================
// 📄 スクリプト:ExplorerJournal.cs(第11弾後編・改修後・全体)
// ==========================================================
using UnityEngine;

// 通路に置く「手記」。調べるとNoteUIを複数ページで開く。
public class ExplorerJournal : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "読む";
    [SerializeField] private NoteUI noteUI;

    // 手記の中身(ページごと)。明るい探索トーンのサンプル文面。
    [SerializeField, TextArea(2, 5)] private string[] pages = new string[]
    {
        "一日目。我らはついに学び舎の入口へたどり着いた。胸が高鳴る。",
        "三日目。広間の中央に、知恵を試す仕掛けがあると記されていた。",
        "五日目。同志の名を、この地の名簿に刻むと約束を交わした。"
    };

    // 💡 後編で追加:読んだときに渡す手がかり
    [Header("読むと渡す手がかり(後編)")]
    [SerializeField] private ClueData[] grants;

    public string GetPrompt() => prompt;

    public void Interact()
    {
        noteUI.Open(pages); // ページ配列を渡して開く

        // ⏳【第3章で有効化】収集帳は第3章で作ります。
        // foreach (var clue in grants)
        //     ClueJournal.Instance.Add(clue);
    }
}

※ Assets/Scripts内の ExplorerJournal.cs を上書き保存します。

ヒエラルキーで ExplorerJournal を選択し、Grants の Size を 3 にして、Clue_WhoMerchant・Clue_WhyHide・Clue_WhoGuard を割り当てます。

📝 手記は「開いた瞬間」に3つまとめて渡します。ページをめくったかどうかは見ません。NoteUI には「ページをめくった」を外へ知らせる口が無く、そのために NoteUI を改修するほどのことでもないからです。読者が「めくったページごとに渡したい」と思ったら、NoteUI に第3章の event を足す、という腕試しになります。


動作確認

ゲーム開始「▶」で確認します(⏳はコメントのままなので、まだ手がかりは集まりません)。

  • 入口の石碑を調べる(一人称で「[E] 調べる」)。1回目は今までの刻文、2回目は「知恵の師」の文、3回目は「地上の町」の文。4回目以降は3回目と同じ

📝 石碑の表面に彫ってある文字は、何回調べても1頁目のまま(想定どおりです。変わるのは、調べたときに開くパネルの文だけ)

  • 掟の石碑も、2回目で「奥の宝物庫」の文になる

  • 左上の言語ドロップダウンで English にしてから調べると、続きの頁も英語で出る

  • 石碑に照準を合わせたとき「[E] 調べる」が出る(届くまでは仮の文言、届いたら翻訳。英語なら「[E] Examine」)

  • コンソールに例外が無い

💪 【腕試し】手がかりの語も多言語にする
ClueData に public LocalizedString wordEntry; を足し、ボード(第4章)で表示するときに取り寄せる、というやり方です。ただし、ブラウザ版では同期の取得(GetLocalizedString())を使わず、この章の「取り寄せて待つ」書き方にしてください。学習効率のため、本記事は直書きのまま進めます。


この章のまとめ

  • 手がかりは「1件=アセット1個」。id は文字列フィールドで持ち、アセット名に頼らない

  • OnValidate は、エディタが呼ぶ入力チェック係

  • 翻訳は「取り寄せ」。GetLocalizedStringAsync() の伝票をコルーチンで待つ

  • 常に見せる文言は StringChanged で「届いたら教えて」にして、毎フレーム取りに行かない

  • 渡す先がまだ無いものは、⏳予約席にしておく


第3章 集めて、保存して、知らせる(ClueJournal と C# event)

主旨

手がかりを「集める」場所、収集帳(ClueJournal)を作ります。やることは3つです。

  1. 覚える
    どの手がかりを持っているか(HashSet。同じものを2回拾っても1つ)

  2. 保存する
    再生し直しても、周回しても残るように(PlayerPrefs)

  3. 知らせる
    集めた瞬間に、画面の数や通知を更新させる(C# の event)

この章の主題は3つ目です。前編第7章で、クリスタルが灯ったことを扉に知らせるのに UnityEvent を使い、「後編では、もう1つの知らせ方、C# の event を使います」と書きました。その回収です。

■ なぜ今回は UnityEvent ではないのか
先に結論を書きます。どちらが優れている、という話ではありません。今回の収集帳は、UnityEvent では配線できないので、C# の event を使います。

UnityEvent は、インスペクターで「呼ぶ相手」をドラッグして配線する仕組みでした。前編の GazeSwitch と扉は、どちらも Main シーンに最初から置いてあるので、ドラッグで繋げました。

収集帳は事情が違います。手がかりはシーンをまたいで持ち続けたいので、収集帳は第10弾後編の AudioManager と同じく、Prefab を Boot と Main の両方に置き、DontDestroyOnLoad で生き残らせます(このあと作ります)。すると、次の2つが起きます。

  • Boot から来たときは、Main 側の収集帳が自滅して、Boot 側が生き残ります。Main 側にインスペクターで配線しても、配線ごと消えます

  • 生き残った Boot 側は、Boot シーンの住人です。Boot シーンのオブジェクトから、Main シーンのHUDをインスペクターで指すことはできません(別のシーンのものはドラッグできません)

放送局からは、受け取り手が見えない。だから、受け取り手のほうが「私にも知らせて」と自分から登録しに行きます。それが C# の event の += です。

📝 迷ったら、「インスペクターでドラッグして繋げるか」で決めてください。繋げるなら UnityEvent が手軽です。繋げないなら C# の event です。

表の最後の行、「外すまで残る」が、この章の目玉つまずきになります。


ClueJournal.cs(収集帳・新規)

置き場所は、第10弾後編の AudioManager と同じ「シングルトン+DontDestroyOnLoad」で、Prefab を Boot と Main の両方に置きます。手がかりはシーンをまたいで持ち続けたいからです。

// ==========================================================
// 📄 スクリプト:ClueJournal.cs(新規)
// ==========================================================
using System;                       // Action(event の型)と StringSplitOptions を使う
using System.Collections.Generic;   // HashSet と List を使う
using UnityEngine;

// 集めた手がかりを覚え、保存し、集めた瞬間に周りへ知らせる「収集帳」。
// 作りは第10弾後編の AudioManager と同じ「シングルトン+DontDestroyOnLoad」。
// Prefab にして Boot と Main の両方に置きます。
public class ClueJournal : MonoBehaviour
{
    public static ClueJournal Instance { get; private set; }

    // --- セーブに使うキー名(第10弾前編と同じく、定数で1か所に集める) ---
    private const string KeyClues  = "Clues";            // 集めた手がかりの id を「|」でつないだ1本の文字列
    private const string KeySolved = "DeductionSolved";  // 推理を当てたか(第4章で使う)
    private const char   Separator = '|';                // id と id の区切り

    [Header("カタログ(手がかりのアセットを全部登録)")]
    [SerializeField] private List<ClueData> catalog = new List<ClueData>();

    // 集めた手がかりの id。HashSet は「同じものを2回入れても1つ」の集合(第8弾の MazeDirector.solved と同じ道具)
    private readonly HashSet<string> collected = new HashSet<string>();

    // 💡 C# の event。「手がかりが1つ増えた」の放送局。
    //    受け取りたいスクリプトは ClueAdded += 自分のメソッド; で配線します
    public event Action<ClueData> ClueAdded;

    public int  Count => collected.Count;   // 集めた数
    public int  Total => catalog.Count;     // 全部の数
    public bool DeductionSolved { get; private set; } // 推理を当てたか(第4章で立てる)

    private void Awake()
    {
        // シングルトンガード(AudioManager と同じ)。Boot 経由で Main に入ると Main 側の2体目が自滅する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        Load(); // 起動時に、保存してある手がかりを読み込む
    }

    // id を持っているか
    public bool Has(string id) => collected.Contains(id);

    // 手がかりを1つ加える。新しく加わったときだけ true(二重取得は無視)
    public bool Add(ClueData clue)
    {
        if (clue == null) return false;
        if (!collected.Add(clue.id)) return false;  // HashSet.Add は「すでにあれば false」

        Save();                       // 失いたくない瞬間なので、その場で保存
        ClueAdded?.Invoke(clue);      // 放送する(誰も聞いていなければ ?. で何もしない)
        return true;
    }

    // 指定の空欄にはまる、集めた手がかりの一覧(第4章のボードが使う)
    public List<ClueData> CollectedOf(ClueSlot slot)
    {
        var list = new List<ClueData>();
        foreach (var clue in catalog)
        {
            if (clue != null && clue.slot == slot && collected.Contains(clue.id))
                list.Add(clue);
        }
        return list;
    }

    // 推理を当てた印を付けて保存する(第4章で呼ぶ)
    public void MarkDeductionSolved()
    {
        DeductionSolved = true;
        PlayerPrefs.SetInt(KeySolved, 1);
        PlayerPrefs.Save();
    }

    // 進行の記録を消す(第6章の「はじめから」で呼ぶ)。持ち主が自分で消します
    public void ResetProgress()
    {
        collected.Clear();
        DeductionSolved = false;
        PlayerPrefs.DeleteKey(KeyClues);
        PlayerPrefs.DeleteKey(KeySolved);
        PlayerPrefs.Save();
    }

    // 集合を「1本の文字列」にして保存する(PlayerPrefs は string / int / float しか持てない)
    private void Save()
    {
        PlayerPrefs.SetString(KeyClues, string.Join(Separator.ToString(), collected));
        PlayerPrefs.Save();
    }

    // 1本の文字列を、区切り文字でほどいて集合に戻す
    private void Load()
    {
        collected.Clear();
        string saved = PlayerPrefs.GetString(KeyClues, "");
        foreach (var id in saved.Split(Separator))   // ★ このままだと1つ困ります(この章の途中で直します)
            collected.Add(id);

        DeductionSolved = PlayerPrefs.GetInt(KeySolved, 0) == 1;
    }

    // 開発用:右クリックメニューから、保存した手がかりを全部消す
    [ContextMenu("手がかりを全部消す(開発用)")]
    private void DebugClear()
    {
        ResetProgress();
        Debug.Log("ClueJournal: 手がかりの記録を消しました");
    }
}

Assets/Scripts内に ClueJournal.cs を作成します。

■ コードの読みどころ

  • public event Action<ClueData> ClueAdded; が放送局です。 Action<ClueData> は「ClueData を1つ受け取って、何も返さないメソッド」の型です。受け取りたい側は ClueAdded += 自分のメソッド; で配線し、ClueAdded -= 自分のメソッド; で外します。放送は ClueAdded?.Invoke(clue); の1行です。?. は「誰も配線していなければ何もしない」の保険(前編までと同じ書き方)です。

  • HashSet<string>.Add は、すでにあれば false を返します。 これが「二重取得の無視」そのものです。同じ石碑を何度読んでも、2回目からは false が返り、保存も放送も起きません。第8弾の MazeDirector で solved に使ったのと同じ道具です。

  • 保存は「1本の文字列」です。 PlayerPrefs には string・int・float しか入りません。集合はそのまま入らないので、string.Join で「who_master|why_test|…」のように1本につなぎます。読み込みは逆に Split でほどきます。区切りの「|」が id に入らないように、第2章の OnValidate で警告を出していました。

  • 保存のタイミングは「拾った瞬間」だけです。手がかりは十数個で、拾う頻度も低いので、そのたびに Save() しても問題ありません(第10弾前編の CollectTreasure と同じ規律)。

  • catalog は「id から ClueData に戻す」ための一覧です。保存してあるのは id(文字列)だけなので、ボードに語を出すには、id に対応する ClueData を探す必要があります。第7弾の TellLottery の Catalog と同じ持ち方です。


Prefab 化して、Boot と Main に置く

第10弾後編の AudioManager とまったく同じ手順です。

  1. ヒエラルキー(Mainシーン)で右クリック ▸ 空のオブジェクトを作成。名前を ClueJournal に(GameManager や AudioManager の隣が見つけやすいです)

  2. 「コンポーネントを追加」▸ ClueJournal をアタッチ

  3. Catalog の Size を 8 にして、Assets/Data/Clues の8つを割り当てる

📝 並び順は、次の順にしてください(Projectウィンドウの名前順と同じです)
Clue_WhereTown・Clue_WhereVault・Clue_WhoGuard・Clue_WhoMaster・Clue_WhoMerchant・Clue_WhyHide・Clue_WhySeal・Clue_WhyTest

  1. ClueJournal を Projectウィンドウの Assets/Prefabs へドラッグして Prefab 化(シーンには残したまま)

  2. Cmd + S で Main を保存

  1. 【Bootシーン】を開き、ClueJournal の Prefab を配置(GameManager Prefab の隣)。Cmd + S で保存

  2. 【Mainシーン】に戻る

📝 Boot から再生すると Boot 側の ClueJournal が生き残り、Main の同じものは Awake の保険で自滅します。Main から直接再生すれば Main 側がそのまま収集帳になります。前編までの管理役と同じ仕組みです。


第2章の⏳を有効化する

StoneMonument.cs と ExplorerJournal.cs を開き、ClueJournal.Instance.Add(...) の行のコメント記号を外します(コンパイルが通ることを確認してください)。

// StoneMonument.cs(ShowPage 内)
ClueJournal.Instance.Add(pageClues[index]);   // ⏳→✅ 有効化

// ExplorerJournal.cs(Interact 内)
foreach (var clue in grants)
    ClueJournal.Instance.Add(clue);           // ⏳→✅ 有効化

これで、石碑と手記を読むと、手がかりが収集帳に入ります。ただし、まだ画面には何も出ません。次に、放送を受け取る側を作ります。


受け取り手①:ClueCounterHUD(数を出す)

画面の隅に「手がかり 3 / 8」と出す小さなHUDです。UIを先に作ります。

■ UIを作る

  1. ヒエラルキー(Mainシーン)の Canvas を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前を ClueCounterText に

  2. Font Asset に日本語フォント(NotoSansJP-Medium SDF)を割り当て(合言葉:忘れると豆腐)

  3. Rect Transform:アンカープリセット bottom left、位置 X:150 / Y:40、幅 260/高さ 40

  4. Font Size 20、Alignment:左揃え+中央(縦)、Vertex Color:白

  5. テキストは仮で「手がかり 0 / 8」

📝 左下を選んだのは、既存のUIと重ならないからです(左上は開発用HUD、上中央は検知ゲージ、右上はミニマップ、下中央はプロンプト)。

■ まず、こう書きたくなる
配線するコードは、素直に書くとこうなります。この章では、わざと2つの困りごとを先に踏みます。 このままアタッチして、動かしてみてください。

// ⚠️ 最初の版(この章の中で2回直します)
using UnityEngine;
using TMPro;

public class ClueCounterHUD : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI label;

    private void Start()
    {
        ClueJournal.Instance.ClueAdded += OnClueAdded;   // 配線する
        Refresh();
    }

    private void OnClueAdded(ClueData clue) => Refresh();

    private void Refresh()
    {
        var journal = ClueJournal.Instance;
        label.text = $"手がかり {journal.Count} / {journal.Total}";
    }
}

Assets/Scripts内に ClueCounterHUD.cs を作成し、ClueCounterText にアタッチ。Label 欄に ClueCounterText 自身をドラッグします。

ゲーム開始「▶」(Main から直接で構いません)。

左下に「手がかり 1 / 8」と出ます。まだ何も読んでいないのに、1つ持っていることになっています。

📝(すでに石碑を読んでいて別の数が出る方は、いったん再生を止め、ヒエラルキーで ClueJournal を選択 → インスペクターの Clue Journal (スクリプト) の見出しを右クリック ▸「手がかりを全部消す(開発用)」を実行してから、もう一度再生してください)

■ 目玉つまずき:何も集めていないのに「1 / 8」
何が起きたのか 保存が無いとき、PlayerPrefs.GetString(KeyClues, "") は空文字を返します。空文字を Split('|') すると、結果は「空文字が1個入った配列」です(C# の仕様です)。それを collected.Add("") するので、「""という手がかり」を1つ持っている状態になります。

直し方は、Split に「空の要素は捨てて」と頼むことです。ClueJournal.cs の Load() の1行を差し替えます。

// 変更前:
foreach (var id in saved.Split(Separator))
// 変更後:空の要素を捨てる
foreach (var id in saved.Split(new[] { Separator }, StringSplitOptions.RemoveEmptyEntries))

再生し直すと「手がかり 0 / 8」になります。石碑を読むと「1 / 8」、手記を読むと一気に「4 / 8」。ここまでは順調です。

📝 区切り文字で集合を保存するやり方は、この「空文字の1個」と「区切り文字が中身に混ざる」の2つだけ気をつければ、十分に実用になります。後者は第2章の OnValidate で警告済みです。


受け取り手②:ClueNotifier(知らせる)

放送の受け取り手を、もう1つ足します。手がかりを得た瞬間に、画面に一言出して、音を鳴らす係です。1つの放送局に、受け取り手が2つ。 これが event の見せ場です。放送局(ClueJournal)は、受け取り手が何人いるか知りません。

// ==========================================================
// 📄 スクリプト:ClueNotifier.cs(新規)
// ==========================================================
using UnityEngine;

// 手がかりを得た瞬間に、画面に一言出して音を鳴らす。受け取り手の2つ目。
// ClueCounterText と同じオブジェクトにアタッチします。
public class ClueNotifier : MonoBehaviour
{
    [SerializeField] private AudioClip clip;   // 手がかりを得た音(空でも動きます)

    private void Start()
    {
        if (ClueJournal.Instance == null) return;
        ClueJournal.Instance.ClueAdded += OnClueAdded;   // 繋ぐ
    }

    private void OnDestroy()
    {
        if (ClueJournal.Instance != null)
            ClueJournal.Instance.ClueAdded -= OnClueAdded; // 外す(対)
    }

    private void OnClueAdded(ClueData clue)
    {
        // 第9弾の ShowMessage(任意の文言をふわっと出す口)を借りる
        JudgeFeedbackUI.Instance?.ShowMessage($"手がかりを得た:{clue.word}");
        AudioManager.Instance?.PlaySe(clip);
    }
}

Assets/Scripts内に ClueNotifier.cs を作成し、ClueCounterText にアタッチ(ClueCounterHUD の隣)。Clip には第10弾後編の Kenney のパックから、短い決定音を1つ選んで割り当てます(例:SE_Lit の流用でも構いません)。

ShowMessage は、ラベルを1つ共有しています。直後に別の表示(宝箱の判定など)が来ると上書きされます。手がかりは石碑と手記でだけ渡すので、実害はありません。宝箱を開けたときにも手がかりを渡したくなったら、通知の見せ方を分ける必要がある、と覚えておいてください。


保存キーの表(第10弾の続き)

前編までと同じ考え方です。プレイの結果は周回では消さず、「はじめから」(第6章)で持ち主が消します。名前と音量(プレイヤーの設定)は、そのときも残します。

📝 ブラウザ版の一言。Web の PlayerPrefs は IndexedDB という仕組みに最大 1MB まで保存されます。id を十数個つないだ文字列は数百バイトなので、余裕です。保存は「拾った瞬間」だけで、毎フレームは呼びません(公式も、プレイ中に頻繁に呼ぶことを勧めていません)。


動作確認

ゲーム開始「▶」で確認します。

『手記』に限っては、一気に3つ増える仕様
  • 石碑を読むと、左下の数が増え、「手がかりを得た:〇〇」がふわっと出て、音が鳴る

  • 同じ頁を読み直しても、数は増えず、通知も出ない(二重取得の無視)

  • 手記を読むと、3つ一気に増える(通知は最後の1つに上書きされて見えます。これは仕様です)

  • 再生を止めて再生し直しても、数が残っている

  • 「タイトルへ」→「はじめる」で戻り、別の手がかりを取っても、数が正しく増え、コンソールにエラーが出ない

  • 再生を止めて、ヒエラルキーの ClueJournal を選択 → インスペクターの Clue Journal (スクリプト) の見出しを右クリック(または右端の「⋮」)▸「手がかりを全部消す(開発用)」→ もう一度再生すると「0 / 8」(空文字の1個が混ざらない)

  • Boot から再生しても、Main から直接再生しても同じに動く


この章のまとめ

  • 放送局がシーンをまたいで生き残り、インスペクターで配線できないときは、C# の event。受け取り手が自分で繋ぐ

  • 繋いだら、必ず外す(Start と OnDestroy、または OnEnable と OnDisable の対)

  • 1つの放送局に、受け取り手は何人いてもよい。放送局は受け取り手のことを知らない

  • 集合は HashSet で持ち、保存は「1本の文字列」にする。空文字の1個と、区切り文字の混入に注意

  • 発行元がシーンをまたいで生き残る(DontDestroyOnLoad)ときほど、外し忘れが事故になる


第4章 推理ボードのUIを組む(DeductionStand と DeductionBoard)

主旨

集めた手がかりを、答えにする場所を作ります。宝物庫の奥の小部屋に「推理の板」を置き、調べると3つの空欄が並んだボードが開きます。集めた語を空欄にはめて「決定」を押すと、3つまとめて判定されます。

後編でいちばんUIの部品が多い章です。前編第3章で、きらめき・まばたき・静けさのうち1つ目を丁寧に作り、残りを同じ骨格の差分で済ませました。UIも同じ考え方で、1行目を丁寧に作り、2・3行目は複製して位置だけ変えます。 Layout Group は使いません。アンカーと数値で並べます(第6弾の NotePanel と同じやり方です)。


設計:3つの部品と、判定の決め方

※ この項は、考え方の説明だけです。Unity での作業は、次の「推理の台を置く(Main)」から始めます。

  • DeductionStand:小部屋に置く台。第5弾の IInteractable で「[E] 推理する」に応え、ボードを開くだけ

  • DeductionBoard:ボードのUI。候補の循環、判定、ヒント。パネル自身に付ける型(第6弾の NoteUI と同じ)

  • ClueJournal:集めた語の出どころ(第3章)。ボードは CollectedOf(slot) で候補をもらう

■ 判定は「3つまとめて」。空欄ごとの正誤は出さない
空欄が3つあると、「1つずつ正解かどうか教えてくれる」作りにしたくなります。ですが、それをすると推理が要らなくなります。候補が3つしかない空欄なら、3回押せば必ず当たるからです。

謎解きゲーム『The Case of the Golden Idol』(2022年)の開発者は、ゲーム開発メディアのインタビューで、選べる語が限られているので空欄ごとの正誤を出すと総当たりができてしまい、かといって全体の正誤だけでは遊ぶ人がいら立つので、「間違いは2つ以下」という中間の表示を加えた、という趣旨のことを語っています。

この記事も同じ考え方でいきます。

  • 判定は3つまとめて。正解は「3つとも合っている」ときだけ

  • 個別の正誤は出さない

  • ヒントをオンにしたときだけ「あと N 個ちがう」を出す(当てずっぽうを少し楽にする程度)

  • 「推理せずに戻る」は常設。推理は結末の条件の1つであって、先に進むための壁ではない(第5章)

■ 空欄に入れるのは「集めた語だけ」
候補は、第3章の収集帳から CollectedOf(slot) で受け取ります。集めていない空欄は「???」で、← → は押せません。集めれば集めるほど候補が増える、という手触りです。

📝 ブラウザ版では日本語の入力欄が使えません(第6弾の名前入力で触れました)。だから語は「打つ」のではなく「選ぶ」形にしています。


推理の台を置く(Main)

前編第7章で作った出口の小部屋(仕切り壁の奥。EndingTrigger がある場所)に置きます。扉を抜けてすぐ左手(西側)です。EndingTrigger の箱は X が 2〜6、Z が 67.5〜68.5 の範囲なので、台はそこにかからない場所に置きます(台を調べるために歩いても、トリガーに触れないように)。

  1. ヒエラルキーで右クリック ▸ 空のオブジェクトを作成。名前を DeductionStand に。位置 (0, 0, 67.2)、回転 (0, 0, 0)、スケール (1, 1, 1)

  2. DeductionStand を右クリック ▸ 3D オブジェクト ▸ キューブ。名前を Base に。位置 (0, 0.5, 0)、スケール (0.8, 1.0, 0.8)。マテリアルは第6弾の手記と同じもの(羊皮紙色)を流用。無ければ新規に作り、ベースカラーを R:200 G:180 B:130 に

  3. Base のインスペクター右上の Layer を Interactable に(レイの当たり先。第5弾)

  4. DeductionStand を右クリック ▸ 3D オブジェクト ▸ テキスト - TextMeshPro。名前を StandText に。位置 (0, 1.01, 0)、回転 (90, -90, 0)(天面に寝かせ、扉のある東から読める向きにする)、Scale (0.05, 0.05, 0.05)。Font Asset に日本語フォント、Font Size 24、Alignment 中央、文字「推理の板」

  5. 台が床に埋まったり浮いたりしたら、親の DeductionStand の位置 Y だけを、ご自分の床の高さに合わせてください(子の Base と StandText の数値は変えません)

文字の高さ 1.01 は、Base の天面(位置 0.5 + 高さ 1.0 の半分 = 1.0)の1cm上です。ぴったり 1.0 にすると、天面と文字が重なってちらつくことがあるので、少しだけ浮かせます。文字が天面の端に寄って見えたり、はみ出して見えたりしたら、高さが浮きすぎています。

天面の文字の向き 回転 X の 90 で文字を寝かせ、回転 Y で「どちらから読むか」を決めます。プレイヤーは扉を抜けて、台の右手(東)から西を向いて近づくので、-90 にしています。寝かせた文字は上から見るので鏡文字にはなりませんが、近づく方向と合っていないと、横倒しや逆さまに見えます。台の置き場所を変えた方は、近づく方向に合わせて選んでください。

📝 台に付けるスクリプト(DeductionStand.cs)は、ボードのスクリプトを作ったあとで作ります。台のスクリプトはボードを参照するので、先に作るとボードができるまでコンパイルエラー(error CS0246)が出続け、その間は新しいスクリプトをアタッチできなくなるためです。


ボードのパネルを作る(1操作ずつ)

※ イメージ図です

Mainシーンの Canvas(第5弾から使っているもの)に組みます。数値は 1920×1080 のゲームビューでの目安です。

■ パネル本体

  1. ヒエラルキーの Canvas を右クリック ▸ UI (Canvas) ▸ パネル。名前を DeductionPanel に

  2. Rect Transform:アンカープリセットでストレッチ(全方向)を選び、Left / Right / Top / Bottom をすべて 120 に

  3. Image の色を黒、アルファ(A)を 200 に

  4. 「コンポーネントを追加」▸ Canvas Group を追加(項目はそのまま。あとでスクリプトが「相互作用可能(Interactable)」を切り替えます)

  5. ヒエラルキーで DeductionPanel を、PausePanel より上(=奥)へドラッグ。並びは上から「ふだんのUI → DeductionPanel → FlashOverlay → PausePanel」

📝 下にあるものほど手前に描かれます(前編第3章)。ボードは、きらめきの板(FlashOverlay)とポーズメニューより奥に置きます。

■ 見出しと、集めた数

■ 見出しと、集めた数

  1. DeductionPanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前を BoardTitle に。Font Asset に日本語フォント。アンカー top center、位置 X:0 / Y:-40、幅 600/高さ 50、Font Size 32、Alignment 中央(横・縦)、文字「推理の板」

  2. 同様にテキストを作成。名前 BoardCountText。アンカー top right、位置 X:-40 / Y:-40、幅 260/高さ 40、Font Size 20、Alignment 右揃え+中央、文字「手がかり 0 / 8」

■ 1行目(誰が)を丁寧に作る
行は「ラベル・←・語・→」の4つで1組です。まず、行の入れ物を作ります。

  1. DeductionPanel を右クリック ▸ 空のオブジェクトを作成。名前を Row_Who に(Canvas の子なので Rect Transform が付いています。もし Transform のままなら、いったん Canvas の外へドラッグして戻すと Rect Transform に変わります)

  2. Row_Who の Rect Transform:アンカー middle center、位置 X:0 / Y:120、幅 900/高さ 70

  3. Row_Who を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 SlotLabel。日本語フォント。アンカー middle left、位置 X:90 / Y:0、幅 160/高さ 50、Font Size 26、Alignment 左揃え+中央、文字「誰が」

  4. Row_Who を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 PrevButton。アンカー middle center、位置 X:-220 / Y:0、幅 60/高さ 56。子の Text (TMP) は文字「←」、日本語フォント、Font Size 24

  5. Row_Who を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 ValueText。日本語フォント。アンカー middle center、位置 X:0 / Y:0、幅 360/高さ 56、Font Size 26、Alignment 中央(横・縦)、文字「???」

  6. Row_Who を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 NextButton。アンカー middle center、位置 X:220 / Y:0、幅 60/高さ 56。子の Text は「→」、日本語フォント、Font Size 24

矢印は「←」「→」にします。第6弾の NoteUI で表示できた文字です。「◀」「▶」は、第6弾で焼いた文字リストに入っていない可能性があり、豆腐になることがあります。

💡 「???」は全角です(第9弾の頭上マークで使った「?」と同じ文字)。豆腐になる方は、半角の「???」に替えるか、第6弾の手順でフォントを焼き直してください。

■ ボタンの仕上げ(複製する前に済ませる)
PrevButton と NextButton に、次の2つを済ませます。複製すると引き継がれるので、ここで済ませておくと残りの行で繰り返さずに済みます。

※ イメージ図です
  1. PrevButton を選択し、Button コンポーネントの「ナビゲーション(Navigation)」のドロップダウンを「Automatic」から 「None」 に

  2. 「コンポーネントを追加」▸ UiClickSound をアタッチし、Clip に第10弾後編のクリック音(SE_UiClick)を割り当て

  3. NextButton にも同じ2つを行う

📝 なぜ Navigation を None にするのか?
ボタンの Navigation は、既定で「Automatic」です。これは「キーボードの矢印やゲームパッドのスティックで、隣のボタンへ選択を移す」機能で、Input System のUI入力では矢印キーや W A S D がその操作に使われます。ボードを開いたまま W や D を押すと、選択の枠が別のボタンへ飛び、押していないのに色が変わって見えることがあります。None にすると、キー操作での選択の移動が無くなり、公式の説明によればクリックしてもフォーカスを受け取らなくなります(押したあとに色が残らない)。このボードはマウスで押す前提なので、None が正解です。試したい方は、Automatic のまま再生して W A S D を押してみてください。

■ 2行目・3行目は複製で

  1. ヒエラルキーで Row_Who を選択し、Cmd + D(Windows は Ctrl + D)で複製。名前を Row_Why に。Rect Transform の位置 Y を 30 に。子の SlotLabel の文字を「何のために」に

  2. もう一度 Row_Who を複製。名前 Row_Where。位置 Y を -60 に。SlotLabel を「どこへ」に

3行とも、← → の Navigation が None、UiClickSound が付いていることを確認してください(複製で引き継がれます)。

■ ヒント・結果・決定・戻る

  1. DeductionPanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 HintButton。アンカー bottom left、位置 X:160 / Y:120、幅 240/高さ 50。子の Text は「ヒント:オフ」、日本語フォント、Font Size 20。Navigation None、UiClickSound

  2. DeductionPanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 ResultText。日本語フォント。アンカー middle center、位置 X:0 / Y:-150、幅 800/高さ 60、Font Size 26、Alignment は横・縦とも中央。文字は空(入っている「New Text」を消す)

  3. DeductionPanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 HintText。日本語フォント。アンカー middle center、位置 X:0 / Y:-200、幅 800/高さ 40、Font Size 20、Alignment は横・縦とも中央、Vertex Color を薄い黄(例:R:255 G:220 B:120)。文字は空

  4. DeductionPanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 DecideButton。アンカー bottom center、位置 X:-140 / Y:60、幅 240/高さ 56。子の Text は「決定」、日本語フォント、Font Size 24。Navigation None、UiClickSound

  5. DeductionPanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 SkipButton。アンカー bottom center、位置 X:140 / Y:60、幅 240/高さ 56。子の Text は「推理せずに戻る」、日本語フォント、Font Size 22。Navigation None、UiClickSound

ヒントの切替を「トグル」ではなく「ボタン」で作っています。uGUI にはトグル(Toggle)もありますが、TextMeshPro 版のトグルはメニューに用意されておらず、ラベルが旧式のテキストになります(日本語フォントの扱いが変わり、これまでの作法から外れます)。ボタンを押すたびに文字を「オン/オフ」で差し替えるほうが、これまでの作法のまま済みます。

📝 この時点では、パネルはゲームビューにずっと表示されています。スクリプトの Awake で閉じるので、このままで構いません(第6弾の LorePanel と同じ)。


DeductionBoard.cs(新規・全文)

// ==========================================================
// 📄 スクリプト:DeductionBoard.cs(新規)
// ==========================================================
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

// 推理ボード。3つの空欄に、集めた語をはめて、まとめて判定する。
// パネル自身(DeductionPanel)にアタッチします(第6弾の NoteUI と同じ型)。
public class DeductionBoard : MonoBehaviour
{
    // 1行ぶんの部品。[Serializable] を付けると、インスペクターに折りたたみで表示されます
    [Serializable]
    public class SlotRow
    {
        public ClueSlot slot;              // この行が担当する空欄
        public Button prevButton;          // ←
        public Button nextButton;          // →
        public TextMeshProUGUI valueText;  // いま選んでいる語
    }

    [Header("パネル")]
    [SerializeField] private GameObject panelRoot;              // DeductionPanel 自身
    [SerializeField] private CanvasGroup canvasGroup;           // DeductionPanel の Canvas Group
    [SerializeField] private CameraModeSwitcher cameraSwitcher; // カーソル復帰用(第6弾の口)

    [Header("3つの空欄(上から 誰が/何のために/どこへ)")]
    [SerializeField] private SlotRow[] rows = new SlotRow[3];

    [Header("正解(手がかりのアセットを3つ)")]
    [SerializeField] private ClueData answerWho;
    [SerializeField] private ClueData answerWhy;
    [SerializeField] private ClueData answerWhere;

    [Header("ボタンと文字")]
    [SerializeField] private Button decideButton;
    [SerializeField] private Button skipButton;
    [SerializeField] private Button hintButton;
    [SerializeField] private TextMeshProUGUI hintButtonLabel; // HintButton の子の Text
    [SerializeField] private TextMeshProUGUI countText;       // BoardCountText
    [SerializeField] private TextMeshProUGUI resultText;
    [SerializeField] private TextMeshProUGUI hintText;

    [Header("音(空でも動きます)")]
    [SerializeField] private AudioClip solvedClip;   // 正解
    [SerializeField] private AudioClip wrongClip;    // 不正解

    [Header("間(ま)")]
    [SerializeField] private float resultHold = 1.5f; // 結果を見せている秒数(この間は押せない)

    private readonly List<ClueData>[] candidates = new List<ClueData>[3]; // 行ごとの候補
    private readonly int[] index = new int[3];                            // 行ごとに、いま何番目を見せているか
    private bool hintOn;
    private bool busy;   // 結果を見せている最中

    private void Awake()
    {
        panelRoot.SetActive(false);   // 最初は閉じておく

        for (int i = 0; i < rows.Length; i++)
        {
            int row = i; // ★ ラムダに渡す番号は、ループの外に写しておく(理由は本文の📝)
            rows[i].prevButton.onClick.AddListener(() => Step(row, -1));
            rows[i].nextButton.onClick.AddListener(() => Step(row, +1));
        }
        decideButton.onClick.AddListener(Decide);
        skipButton.onClick.AddListener(Close);
        hintButton.onClick.AddListener(ToggleHint);
    }

    // 台(DeductionStand)から呼ばれる
    public void Open()
    {
        UiGate.IsOpen = true;                     // ★ ゲート閉:ゲーム側の入力を止める
        Cursor.lockState = CursorLockMode.None;   // ★ カーソル解放(ボタンを押すため)
        Cursor.visible = true;
        panelRoot.SetActive(true);
        canvasGroup.interactable = true;
        resultText.text = "";
        hintText.text = "";

        // 収集帳から、行ごとの候補をもらう(集めた語だけ)
        var journal = ClueJournal.Instance;
        for (int i = 0; i < rows.Length; i++)
        {
            candidates[i] = journal.CollectedOf(rows[i].slot);
            index[i] = 0;
        }
        countText.text = $"手がかり {journal.Count} / {journal.Total}";

        if (journal.DeductionSolved) ShowSolved();   // もう当てていれば、答えを固定して見せる
        else RefreshAll();
    }

    // ← → で候補を1つずらす(端まで行ったら反対側へ回る)
    private void Step(int row, int dir)
    {
        int count = candidates[row].Count;
        if (count == 0) return;                          // 候補が無ければ何もしない
        index[row] = (index[row] + dir + count) % count; // 循環(-1 も count を足してから割るので負にならない)
        RefreshRow(row);
    }

    private void RefreshAll()
    {
        for (int i = 0; i < rows.Length; i++) RefreshRow(i);
        decideButton.interactable = true;
    }

    // 1行ぶんの表示を更新する
    private void RefreshRow(int row)
    {
        var r = rows[row];
        int count = candidates[row].Count;

        r.valueText.text = (count == 0) ? "???" : candidates[row][index[row]].word;

        bool canCycle = count >= 2;           // 2つ以上あるときだけ、← → が意味を持つ
        r.prevButton.interactable = canCycle;
        r.nextButton.interactable = canCycle;
    }

    // 当てたあとに開いたとき:答えを見せて、触れなくする
    private void ShowSolved()
    {
        rows[0].valueText.text = answerWho.word;
        rows[1].valueText.text = answerWhy.word;
        rows[2].valueText.text = answerWhere.word;
        foreach (var r in rows)
        {
            r.prevButton.interactable = false;
            r.nextButton.interactable = false;
        }
        decideButton.interactable = false;
        resultText.text = "この謎は、もう解き明かした";
    }

    // 「決定」:3つまとめて判定する
    private void Decide()
    {
        if (busy) return;

        // 空欄が残っていれば、判定しない
        for (int i = 0; i < rows.Length; i++)
        {
            if (candidates[i].Count == 0)
            {
                resultText.text = "手がかりが足りない。空欄を埋めよう";
                return;
            }
        }

        // 違っている空欄の数を数える(どれが違うかは教えない)
        int wrong = 0;
        if (candidates[0][index[0]] != answerWho)   wrong++;
        if (candidates[1][index[1]] != answerWhy)   wrong++;
        if (candidates[2][index[2]] != answerWhere) wrong++;

        if (wrong == 0)
        {
            StartCoroutine(SolvedRoutine());
        }
        else
        {
            resultText.text = "どこかが違うようだ";
            hintText.text = hintOn ? $"あと {wrong} 個ちがう" : "";
            AudioManager.Instance?.PlaySe(wrongClip);
            StartCoroutine(LockRoutine());
        }
    }

    // 結果を見せている間、押せなくする(連打での二重判定を防ぐ)
    private IEnumerator LockRoutine()
    {
        busy = true;
        canvasGroup.interactable = false;   // 見えるが、押せない
        yield return new WaitForSeconds(resultHold);
        canvasGroup.interactable = true;
        busy = false;
    }

    // 正解:印を付けて、光って、閉じる
    private IEnumerator SolvedRoutine()
    {
        busy = true;
        canvasGroup.interactable = false;

        ClueJournal.Instance.MarkDeductionSolved();   // 保存(第5章の隠しの結末の条件になる)
        resultText.text = "真相にたどり着いた!";
        hintText.text = "";
        AudioManager.Instance?.PlaySe(solvedClip);
        ScreenFlash.Instance?.Flash();               // 前編第3章のきらめき

        yield return new WaitForSeconds(resultHold);

        canvasGroup.interactable = true;
        busy = false;
        Close();
    }

    // ヒントのオン/オフ
    private void ToggleHint()
    {
        hintOn = !hintOn;
        hintButtonLabel.text = hintOn ? "ヒント:オン" : "ヒント:オフ";
        if (!hintOn) hintText.text = "";
    }

    // 閉じる:3点セット(パネルを閉じる・ゲートを開ける・カーソルを戻す)
    private void Close()
    {
        busy = false;
        canvasGroup.interactable = true;
        panelRoot.SetActive(false);
        UiGate.IsOpen = false;
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode();
    }
}

Assets/Scripts内に DeductionBoard.cs を作成します。

■ コードの読みどころ

  • 候補は「集めた語だけ」
    Open() のたびに収集帳から CollectedOf(slot) でもらい直します。開くたびに最新になります。開いた直後に各行に出るのは、候補の先頭(第3章で決めた Catalog の並び順で最初のもの)です。

  • 循環は (index + dir + count) % count
    -1 を足すと負になるので、先に count を足してから割ります。候補が0のときは何もしません(範囲外の事故を防ぐ)。候補が1つのときは ← → を押せなくします。

  • 判定は Decide() の1か所
    違う空欄の数だけ数えて、どれが違うかは教えません。正解を知っているのは answerWho・answerWhy・answerWhere の3つの参照だけです。台帳(ClueData)に「これが正解」という印は持たせません。同じ空欄に正解が2つできる事故を防ぐためです。

  • 「見えるが押せない」は Canvas Group
    結果を見せている1.5秒は canvasGroup.interactable = false にします。パネルは見えたまま、中のボタンだけ灰色になって押せなくなります。前編第3章のきらめきの板と同じ道具の、別の使い方です。

  • 当てたあとは答えを固定
    DeductionSolved が保存されているので、再生し直しても、次の周回でも、開けば答えが見えます。

📝 ループの中でラムダ式に番号を渡すときの罠 Awake の int row = i; の1行は、消したくなりますが消せません。for のループ変数 i をそのままラムダ(() => Step(i, -1))に書くと、3つのボタンが全部「ループが終わったときの i(=3)」を見てしまい、範囲外エラーになります。ループの中で別の変数に写してから渡すと、行ごとに別の番号が固定されます。C# の初学者が一度は踏む罠なので、先に書いておきます。


DeductionStand.cs(新規)

ボードができたので、台のスクリプトを作ります。調べるとボードを開くだけの、小さな口です。

// ==========================================================
// 📄 スクリプト:DeductionStand.cs(新規)
// ==========================================================
using UnityEngine;

// 推理の台。調べるとボードを開くだけの、小さな口(第6弾の RegisterDesk と同じ作り)。
public class DeductionStand : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "推理する";
    [SerializeField] private DeductionBoard board;   // 次の節で作るボード

    public string GetPrompt() => prompt;
    public void Interact() => board.Open();
}

Assets/Scripts内に DeductionStand.cs を作成します。
※ アタッチと割り当ては、次項にてボードと一緒に行います。


参照を割り当てる

DeductionPanel を選択し、「コンポーネントを追加」▸ DeductionBoard をアタッチして、上から順に割り当てます。

  • Panel Root:DeductionPanel 自身

  • Canvas Group:DeductionPanel 自身(付いている Canvas Group が入ります)

  • Camera Switcher:アバター(model_test)

  • Rows:Size 3。要素0は Slot=Who、Prev Button=Row_Who/PrevButton、Next Button=Row_Who/NextButton、Value Text=Row_Who/ValueText。要素1は Why と Row_Why の各部品、要素2は Where と Row_Where の各部品

  • Answer Who:Clue_WhoMaster/Answer Why:Clue_WhyTest/Answer Where:Clue_WhereVault

  • Decide Button:DecideButton/Skip Button:SkipButton/Hint Button:HintButton/Hint Button Label:HintButton の子の Text (TMP)

  • Count Text:BoardCountText/Result Text:ResultText/Hint Text:HintText

  • Solved Clip:正解の音(例:SE_ChestOpen の流用)/Wrong Clip:不正解の音(例:SE_ChestWrong の流用)

  • Result Hold:1.5

最後に、ヒエラルキーの DeductionStand(親。子の Base ではありません)を選択し、「コンポーネントを追加」▸ DeductionStand をアタッチします。Board 欄に DeductionPanel をドラッグし、Prompt は「推理する」のままにします。Cmd + S でシーンを保存します。

⚠️ Rows の要素の中身を1つでも入れ忘れると、その行だけ無言で動きません(第9弾と同じ)。3行×3欄の9つを見直してください。


動作確認

ゲーム開始「▶」で確認します(Main から直接で構いません。推理の台までは、前編どおり扉を開けて進んでください。試すだけなら、台を一時的に入口の広間へ動かしても構いません。確認が済んだら戻してください)。

  • 一人称で台に照準を合わせると「[E] 推理する」。E でボードが開き、カーソルが出る

  • 集めた語だけが候補に出る。集めていない空欄は「???」で、← → が灰色

  • 候補が2つ以上ある空欄で ← → を押すと語が変わり、端で反対側へ回る

  • 空欄が残ったまま「決定」→「手がかりが足りない」

  • わざと違う組み合わせで「決定」→「どこかが違うようだ」。1.5秒間はボタンが灰色で押せない

  • 「ヒント:オフ」を押すとオンになり、外したときに「あと N 個ちがう」が出る

  • 「知恵の師/知恵を試すため/奥の宝物庫へ」で「決定」→ 光って「真相にたどり着いた!」→ 閉じる。もう一度開くと答えが固定されている

  • ボードを開いたまま W A S D を押しても、選択の枠が飛ばない(Navigation None)。Esc を押してもポーズは開かない(既存の PauseMenu の仕様)

  • 「推理せずに戻る」で閉じると、操作とカーソルが戻る

■ 動作確認の流れの例

手がかりが1つだけの状態。集めていない「誰が」と「どこへ」は「???」で、← → は灰色になって押せません。このまま「決定」を押すと、判定はせずに「手がかりが足りない。空欄を埋めよう」と出ます(ヒントがオンでも、空欄が残っていれば何個違うかは出ません)。

石碑と手記を読んで、手がかりが6つになった状態。「どこへ」の候補は「地上の町へ」の1つだけなので、← → は灰色のままです。この組み合わせで「決定」を押すと「どこかが違うようだ」。どの空欄が違うかは教えません。

番人の近くにある掟の石碑まで読み、8つすべてそろった状態。「知恵の師/知恵を試すため/奥の宝物庫へ」を選んで「決定」を押すと、画面が光って「真相にたどり着いた!」と出て、ボードが閉じます。

■ 腕試し

  • 候補を減らすヒント:ClueData に public bool hintRemovable; を足し、ヒントがオンのときは CollectedOf の結果からそれを外す。誤答を1つ減らすだけで、手触りが変わります

  • ドロップダウンで選ぶ:← → の代わりに、第6弾の LanguageDropdown と同じ TMP_Dropdown で候補を並べる


この章のまとめ

  • 穴埋め型の推理は「集めた語だけを候補にし、3つまとめて判定し、個別の正誤は出さない」

  • UI は「1行を丁寧に作り、複製して位置だけ変える」。ボタンの Navigation は None にし、複製する前に済ませる

  • 「見えるが押せない」は Canvas Group の Interactable

  • ループの中のラムダには、番号を写してから渡す


第5章 結末を分ける(EndingChoiceUI・EndingType 3種・静けさ)

主旨

出口に触れた瞬間に終わっていたエンディングを、「選ぶ」形にします。結末は3種類に増やします。

第10弾前編の GameManager に「今回はNormalの1つだけ(3種に増えるのは第11弾)」と書き、EndingMenu には「第11弾で『どのエンドか』による分岐がここに入ります」と書きました。第7章の末尾では「『集めた数でエンディングが変わる』仕掛けは、第11弾のマルチエンディングで結線します」とも。この章で、3つとも回収します。

■ ルール

  • 秘宝の数は「選べる選択肢」を増やします

  • 推理の達成は「隠しの結末」の条件にだけ効きます。秘宝の数は問いません(推理だけで真相に届く人がいてもよい)

  • 隠しのボタンは、条件を満たすまで存在させません。灰色で見せると、存在そのものがネタバレになるからです

  • 「引き返す」を付けます。推理を後回しにした人が、台へ戻れるように

📝 判定は1か所(EndingChoiceUI.Open())に集めます。「条件を満たしたボタンだけ SetActive(true)」の3行です。あとから結末を4つ目に増やすときも、ここと EndingType を触るだけで済みます。


GameManager.cs(改修後・全体。この記事で開くのは1回)

3つの変更を1回で入れます。結末を3種類にすること、保存してある「前回の結末」を読む口(第6章で使う)、進行の記録を消す口(第6章の「はじめから」で使う)です。

// ==========================================================
// 📄 スクリプト:GameManager.cs(第11弾後編・改修後・全体)
// ==========================================================
using UnityEngine;

// ゲーム全体の進行状態を1か所で預かる管理役(完成形)。
// 第10弾前編からシーンをまたいで生き残ります(DontDestroyOnLoad)。
public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    // --- セーブに使うキー名。タイポ防止のため定数で1か所に集めます ---
    private const string KeyTreasure = "TreasureFlags";
    private const string KeyLap      = "LapCount";
    private const string KeyCleared  = "HasCleared";
    private const string KeyEnding   = "LastEnding";

    public enum DepthPhase { Shallow, Middle, Deep }

    // 💡 後編で変更:結末を3種類に(第10弾前編の「3種に増えるのは第11弾」の回収)
    public enum EndingType
    {
        Normal   = 0,   // 探索を終える
        Treasure = 1,   // 秘宝を持ち帰る(秘宝3つ)
        Truth    = 2    // 真相を告げる(推理を当てた)
    }

    // static変数によるシーン間の受け渡し(最軽量の伝言板)。
    // Endingシーンのスクリプトが「どのエンドだったか」をここから読みます。
    public static EndingType LastEnding = EndingType.Normal;

    [SerializeField] private DepthPhase currentPhase = DepthPhase.Shallow;
    public DepthPhase CurrentPhase => currentPhase;

    public int MistakeCount { get; private set; }

    // 秘宝は「数」ではなく「ビットフラグ」で持ちます
    public int TreasureFlags { get; private set; }

    // 秘宝の数は、フラグから「点いている電球」を数えて算出します
    public int TreasureCount
    {
        get
        {
            int count = 0;
            for (int i = 0; i < 3; i++)
                if ((TreasureFlags & (1 << i)) != 0) count++;
            return count;
        }
    }

    public int LapCount { get; private set; }
    public bool HasClearedOnce { get; private set; }

    public bool IsJudgementInverted => currentPhase == DepthPhase.Deep;

    public string PhaseLabel => currentPhase switch
    {
        DepthPhase.Shallow => "浅層",
        DepthPhase.Middle  => "中層",
        DepthPhase.Deep    => "深層",
        _ => "不明"
    };

    // 💡 後編で追加:結末の表示名(Ending・Title・記念碑が使う。第7弾の switch式)
    public static string EndingLabel(EndingType type) => type switch
    {
        EndingType.Treasure => "秘宝を持ち帰った",
        EndingType.Truth    => "真相にたどり着いた",
        _                   => "探索を終えた",
    };

    // 💡 後編で追加:保存してある「前回の結末」を読む口。
    //    static の LastEnding ではなく、保存した値を読みます(理由は第6章)
    public bool HasSavedEnding => PlayerPrefs.HasKey(KeyEnding);
    public EndingType SavedLastEnding => (EndingType)PlayerPrefs.GetInt(KeyEnding, 0);

    private void Awake()
    {
        // シングルトンガード(第4弾からの型)。Boot経由でMainに入ると、Mainに置いてある2体目がここで自滅します
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        // 起動時にセーブデータを読み込む
        TreasureFlags  = PlayerPrefs.GetInt(KeyTreasure, 0);
        LapCount       = PlayerPrefs.GetInt(KeyLap, 0);
        HasClearedOnce = PlayerPrefs.GetInt(KeyCleared, 0) == 1;
    }

    public void AddMistake() => MistakeCount++;

    public void AdvancePhase()
    {
        if (currentPhase == DepthPhase.Shallow)      currentPhase = DepthPhase.Middle;
        else if (currentPhase == DepthPhase.Middle)  currentPhase = DepthPhase.Deep;
    }

    public bool HasTreasure(int index) => (TreasureFlags & (1 << index)) != 0;

    public void CollectTreasure(int index)
    {
        TreasureFlags |= (1 << index);
        PlayerPrefs.SetInt(KeyTreasure, TreasureFlags);
        PlayerPrefs.Save();
    }

    // エンディング到達の記録(後編からは EndingChoiceUI が呼びます)
    public void NotifyEndingReached(EndingType type)
    {
        LastEnding = type;
        LapCount++;
        HasClearedOnce = true;
        PlayerPrefs.SetInt(KeyLap, LapCount);
        PlayerPrefs.SetInt(KeyCleared, 1);
        PlayerPrefs.SetInt(KeyEnding, (int)type);
        PlayerPrefs.Save();
    }

    // 周回の開始準備。「その周だけの状態」をリセットします(タイトルの「はじめる」で呼ぶ)
    public void ResetRunState()
    {
        currentPhase = DepthPhase.Shallow;
        MistakeCount = 0;
    }

    // 💡 後編で追加:進行の記録を消す(第6章の「はじめから」で呼ぶ)。
    //    DeleteAll は使いません。名前(第6弾)と音量(第10弾後編)まで消えてしまうからです。
    //    持ち主(GameManager)が、自分のキーだけを消します
    public void ResetProgress()
    {
        TreasureFlags  = 0;
        LapCount       = 0;
        HasClearedOnce = false;
        LastEnding     = EndingType.Normal;

        PlayerPrefs.DeleteKey(KeyTreasure);
        PlayerPrefs.DeleteKey(KeyLap);
        PlayerPrefs.DeleteKey(KeyCleared);
        PlayerPrefs.DeleteKey(KeyEnding);
        PlayerPrefs.Save();
    }
}

※ Assets/Scripts内の GameManager.cs を上書き保存します。

EndingLabel を static にしたのは、GameManager.EndingLabel(type) と書けば、インスタンスが無い場面(Title シーンの Start など)でも使えるからです。「値を1つ返すだけ」なので switch式(第7弾)が合います。


結末を選ぶパネルを作る(1操作ずつ)

Mainシーンの Canvas に組みます。第6弾の NameEntryPanel と同じ、小さめのパネルです。

※ イメージ図
  1. Canvas を右クリック ▸ UI (Canvas) ▸ パネル。名前を EndingChoicePanel に。アンカープリセットでストレッチ(全方向)、Left / Right / Top / Bottom を 200。Image の色は黒、A:220

  2. ヒエラルキーで EndingChoicePanel を DeductionPanel のすぐ下(FlashOverlay より上)へ移動

  3. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 ChoiceTitle。日本語フォント。アンカー top center、位置 X:0 / Y:-40、幅 600/高さ 50、Font Size 30、Alignment は横・縦とも中央、文字「探索を終える」

  4. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 ChoiceInfo。日本語フォント。アンカー top center、位置 X:0 / Y:-100、幅 700/高さ 60、Font Size 22、Alignment は横・縦とも中央、文字は仮で「秘宝 0 / 3  推理:まだ」

  5. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 NormalEndButton。アンカー middle center、位置 X:0 / Y:60、幅 320/高さ 56。子の Text は「探索を終える」、日本語フォント、Font Size 24。Navigation None、UiClickSound

  6. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 TreasureEndButton。アンカー middle center、位置 X:0 / Y:0、幅 320/高さ 56。子の Text は「秘宝を持ち帰る」、日本語フォント、Font Size 24。Navigation None、UiClickSound。インスペクター左上のチェックを外して非アクティブに

  7. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 TruthEndButton。アンカー middle center、位置 X:0 / Y:-60、幅 320/高さ 56。子の Text は「真相を告げる」、日本語フォント、Font Size 24。Navigation None、UiClickSound。非アクティブに

  8. EndingChoicePanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 ReturnButton。アンカー bottom center、位置 X:0 / Y:50、幅 240/高さ 50。子の Text は「引き返す」、日本語フォント、Font Size 22。Navigation None、UiClickSound

非アクティブにした2つのボタンは、条件を満たしたときだけスクリプトが SetActive(true) にします。第7弾の Treasure(非アクティブでも参照を割り当てられる)と同じで、見えなくても割り当てはできます。

📝 この時点では、パネル自体はゲームビューに表示されたままです。第4章と同じく、スクリプトの Awake で閉じます。

関数の一覧は、オブジェクト欄に入れたもの(EndingChoicePanel)に付いているコンポーネントごとに分かれています。「No Function」をクリックして「EndingChoiceUI」にマウスを合わせると、右に開くサブメニューに OnClickNormal () などが並びます。一覧に「EndingChoiceUI」そのものが無いときは、EndingChoicePanel にスクリプトが付いていません(付け忘れか、別のオブジェクトに付けている)。コンソールに赤いエラーが出ていてアタッチできないときは、この章の最初の GameManager.cs の差し替えが済んでいるか確かめてください。


EndingChoiceUI.cs(新規・全文)

// ==========================================================
// 📄 スクリプト:EndingChoiceUI.cs(新規)
// ==========================================================
using UnityEngine;
using TMPro;

// 出口で結末を選ぶパネル。パネル自身(EndingChoicePanel)にアタッチします。
// ボタンは第10弾前編のタイトル画面と同じ「インスペクターのクリック時 ()」で結線します。
public class EndingChoiceUI : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;              // EndingChoicePanel 自身
    [SerializeField] private TextMeshProUGUI infoText;          // ChoiceInfo
    [SerializeField] private GameObject treasureEndButton;      // 条件付きで出すボタン(GameObject ごと)
    [SerializeField] private GameObject truthEndButton;
    [SerializeField] private CameraModeSwitcher cameraSwitcher; // カーソル復帰用

    [Header("静けさ(前編第3章の部品を、ここで初めて使う)")]
    [SerializeField] private float hushVolume  = 0.15f; // どこまで音を引くか(0〜1)
    [SerializeField] private float hushSeconds = 1.0f;  // 引くのにかける秒数

    private bool choosing; // 選んだあとの連打を防ぐ

    private void Awake()
    {
        panelRoot.SetActive(false);   // 最初は閉じておく
    }

    // EndingTrigger から呼ばれる
    public void Open()
    {
        var gm = GameManager.Instance;
        var journal = ClueJournal.Instance;

        UiGate.IsOpen = true;                    // ★ ゲート閉
        StageGate.IsPlaying = true;              // 念のため、演出の旗も立てる(番人・視点切替を止める)
        Cursor.lockState = CursorLockMode.None;  // ★ カーソル解放
        Cursor.visible = true;
        panelRoot.SetActive(true);
        choosing = false;

        // 💡 判定はここ1か所。条件を満たしたボタンだけ、存在させる
        bool allTreasure = gm.TreasureCount >= 3;
        bool solved = (journal != null) && journal.DeductionSolved;
        treasureEndButton.SetActive(allTreasure);
        truthEndButton.SetActive(solved);

        infoText.text = $"秘宝 {gm.TreasureCount} / 3  推理:{(solved ? "解き明かした" : "まだ")}";

        // 💡 静けさ:音がすっと引く。選ぶまでの「間」を作る
        AudioManager.Instance?.FadeMaster(hushVolume, hushSeconds);
    }

    // --- ボタンから呼ばれる4つ ---

    public void OnClickNormal()   => Choose(GameManager.EndingType.Normal);
    public void OnClickTreasure() => Choose(GameManager.EndingType.Treasure);
    public void OnClickTruth()    => Choose(GameManager.EndingType.Truth);

    public void OnClickReturn()
    {
        if (choosing) return;
        AudioManager.Instance?.FadeMaster(1f, 0.5f);   // 音を戻す
        panelRoot.SetActive(false);                    // 3点セット+演出の旗
        UiGate.IsOpen = false;
        StageGate.IsPlaying = false;
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode();
    }

    // 選んだ:記録して、暗転してエンディングへ。
    // 音量・時間・ゲート・演出の旗は、ScreenFader の関所が暗転の裏で必ず戻します(第10弾前編・前編第2章)
    private void Choose(GameManager.EndingType type)
    {
        if (choosing) return;
        choosing = true;
        GameManager.Instance.NotifyEndingReached(type);   // 周回数++・クリア済み・結末を保存
        ScreenFader.Instance.FadeAndLoad("Ending");
    }
}

Assets/Scripts内に EndingChoiceUI.cs を作成し、EndingChoicePanel にアタッチ。参照を割り当てます。

  • Panel Root:EndingChoicePanel 自身

  • Info Text:ChoiceInfo

  • Treasure End Button:TreasureEndButton(非アクティブのまま割り当て可)

  • Truth End Button:TruthEndButton

  • Camera Switcher:アバター(model_test)

  • Hush Volume:0.15/Hush Seconds:1

続いて、4つのボタンをインスペクター方式で結線します(第10弾前編のタイトルと同じ手順)。

例:NormalEndButton
  1. NormalEndButton を選択 → Button の「クリック時 ()(On Click ())」の「+」→ オブジェクト欄に EndingChoicePanel をドラッグ → 関数を EndingChoiceUI ▸ OnClickNormal ()

  2. TreasureEndButton → OnClickTreasure ()

  3. TruthEndButton → OnClickTruth ()

  4. ReturnButton → OnClickReturn ()

📝 非アクティブのボタンも、選択すればインスペクターで結線できます。

関数の一覧は、オブジェクト欄に入れたもの(EndingChoicePanel)に付いているコンポーネントごとに分かれています。「No Function」をクリックして「EndingChoiceUI」にマウスを合わせると、右に開くサブメニューに OnClickNormal () などが並びます。一覧に「EndingChoiceUI」そのものが無いときは、EndingChoicePanel にスクリプトが付いていません(付け忘れか、別のオブジェクトに付けている)。コンソールに赤いエラーが出ていてアタッチできないときは、この章の最初の GameManager.cs の差し替えが済んでいるか確かめてください。

■ 「静けさ」の使いどころ

前編第3章で作った FadeMaster を、ここで初めて使います。パネルが開いた瞬間に1秒かけて音量を 15% まで引き、選ぶまでの間を作ります。選ぶと暗転が始まり、暗転の裏で関所(ScreenFader)が AudioListener.volume = 1fに戻します。 前編第3章で、関所に足した1行が実際に働くところは後編で確かめる、と書きました。その確認が、この章の動作確認です。

「引き返す」のときは、自分で FadeMaster(1f, 0.5f) で戻します。関所を通らない経路だからです(第10弾後編で書いた、共通の関所があるものは関所に1か所、無いものは出口ごと、という判断と同じです)。


EndingTrigger.cs(改修後・全体)

出口のトリガーは、エンディングへ直行するのをやめて、パネルを開くだけにします。

// ==========================================================
// 📄 スクリプト:EndingTrigger.cs(第11弾後編・改修後・全体)
// ==========================================================
using UnityEngine;

// 宝物庫の最奥。触れたら結末を選ぶパネルを開く。
public class EndingTrigger : MonoBehaviour
{
    [SerializeField] private EndingChoiceUI choice;   // 💡 後編で追加:結末を選ぶパネル

    private bool fired; // 中にいる間、二度は開かない

    private void OnTriggerEnter(Collider other)
    {
        if (fired || UiGate.IsOpen) return;   // 💡 後編で変更:別のUIが開いていれば開かない
        if (other.GetComponent<CharacterController>() == null) return;
        fired = true;

        // 変更前:GameManager.Instance.NotifyEndingReached(GameManager.EndingType.Normal);
        //         ScreenFader.Instance.FadeAndLoad("Ending");
        choice.Open();   // 💡 後編で変更:記録と遷移はパネル側へ移った
    }

    // 💡 後編で追加:出たら、もう一度入れるようにする
    private void OnTriggerExit(Collider other)
    {
        if (other.GetComponent<CharacterController>() == null) return;
        fired = false;
    }
}

※ Assets/Scripts内の EndingTrigger.cs を上書き保存します。

ヒエラルキーの EndingTrigger を選択し、Choice 欄に EndingChoicePanel を割り当てます。

📝 なぜ Exit で戻すのか 「引き返す」で閉じたあと、プレイヤーはまだトリガーの箱の中にいます。閉じた瞬間に fired を戻すと、その場で即座にまた開いてしまいます。「一度外へ出てから入り直したら開く」にするには、Exit で戻すのがいちばん素直です。なお、コライダーを無効化して代用する手もありますが、Unityの公式リファレンスには、中にいる間にコライダーを無効化したり破棄したりしても OnTriggerExit は送られない、と明記されています。Exit を当てにするなら、無効化はしないことです。


EndingMenu.cs(改修後・全体)

Endingシーンの文言を、結末で分けます。「static伝言板(LastEnding)はここで読みます」と書いておいた場所です。Ending は必ず Main から同じセッション内で来るので、ここは static のままで構いません。

// ==========================================================
// 📄 スクリプト:EndingMenu.cs(第11弾後編・改修後・全体)
// ==========================================================
using UnityEngine;
using TMPro;

// エンディング画面。結末に応じて文言を変え、タイトルへ送り返します。
public class EndingMenu : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI titleText;   // 💡 後編で追加:EndTitleText
    [SerializeField] private TextMeshProUGUI resultText;  // ResultText

    private void Start()
    {
        string name = PlayerPrefs.GetString("PlayerName", "名もなき冒険者");
        var gm = GameManager.Instance;

        // 💡 後編で追加:どの結末かで、見出しと本文を切り替える
        switch (GameManager.LastEnding)
        {
            case GameManager.EndingType.Treasure:
                titleText.text  = "― 秘宝を手に ―";
                resultText.text =
                    $"{name} は、三つの秘宝をすべて見抜き、地上へ持ち帰った。\n" +
                    $"({gm.LapCount} 周目の探索)";
                break;

            case GameManager.EndingType.Truth:
                titleText.text  = "― 真相 ―";
                resultText.text =
                    "知恵の師は、知恵を試すために、秘宝を奥の宝物庫に置いた。\n" +
                    $"{name} は、その真相にたどり着いた。({gm.LapCount} 周目の探索)";
                break;

            default: // Normal
                titleText.text  = "― 探索完了 ―";
                resultText.text =
                    $"{name} は、{gm.LapCount} 周目の探索を終えた。\n" +
                    $"手にした秘宝:{gm.TreasureCount} / 3";
                break;
        }
    }

    public void OnClickToTitle()
    {
        ScreenFader.Instance.FadeAndLoad("Title");
    }
}

※ Assets/Scripts内の EndingMenu.cs を上書き保存します。

【Endingシーン】を開き、EndingMenu の Title Text 欄に EndTitleText を割り当て、Cmd + S で保存して【Mainシーン】へ戻ります。

📝 真相の文は、第2章の正解3つ(知恵の師/知恵を試すため/奥の宝物庫へ)を1つの文にしたものです。題材を置き換えた方は、ここも書き換えてください。


動作確認

【Bootシーン】から再生します(周回数の増え方まで見るため)。
推理の記録と秘宝は保存されているので、出口で見えるボタンは、それまでのプレイで決まります。第4章で推理を当てたままだと、最初から「真相を告げる」が出てしまいます。まず、再生を止めて ClueJournal の「手がかりを全部消す(開発用)」を実行し、推理の記録を消してから始めてください。

■ テストパターンの例
「手がかりを集め推理ボードで真実を解く」「秘宝を集める」など、さまざまなパターンで動作確認してください。全てあげるとキリがないので、今回は一例を紹介します。

秘宝0・推理まだで出口へ

秘宝0、推理はまだの状態で出口の小部屋に入ると、パネルが開き、音がすっと引きます。ボタンは「探索を終える」と「引き返す」の2つだけです。条件を満たしていない結末のボタンは、灰色ではなく、そもそも表示されません。

秘宝3つで出口へ

秘宝を3つ集めてから出口に入ると、「秘宝を持ち帰る」が増えます。上の文字も「秘宝 3 / 3 推理:まだ」に変わっています。推理はまだ解いていないので、「真相を告げる」は出ていません。

推理を当ててから入り直す

「引き返す」でトリガーの外へ出て、推理の台で推理を当ててから、もう一度出口に入ったところ。「推理:解き明かした」になり、「真相を告げる」が加わって、結末のボタンが3つそろいました。第4章の推理の結果が、ここの選択肢を変えています。

「真相を告げる」を選ぶ

「真相を告げる」を選ぶと暗転し、Ending シーンの見出しが「― 真相 ―」に、本文が真相の文に変わります。末尾の「1 周目の探索」は、結末を選んだ瞬間に増えた周回数です。

■ 必ず確かめてほしいこと(2つ)

  • 「引き返す」のあと、トリガーの外へ一度出てから入り直すと、パネルがもう一度開く(その場に立ったままでは開きません。Exit で再武装する作りです)

  • 結末を選んで Ending シーンに切り替わったとき、曲が普通の大きさで鳴っている

📝 なぜ音量を確かめるのか パネルが開くと、「静けさ」でゲーム全体の音量(AudioListener.volume)を 15% まで下げます。結末を選ぶと、この音量を戻さないまま暗転してシーンを切り替えます。全体の音量はシーンをまたいでも残るので、誰も戻さなければ、エンディングもタイトルも、ずっと小さいままになってしまいます。それを防ぐのが、前編で関所(ScreenFader)に足した「暗転の間に、全体の音量を必ず 1 に戻す」1行です。前編では、この1行が働く場面がまだ無かったので、ここで初めて確かめます。 なお、ポーズメニューの音量スライダー(第10弾後編)は、BGM・効果音・環境音それぞれの音量を動かす別の仕組みです。「静けさ」はスライダーの値には触れないので、スライダーの位置は変わりません。

テストのたびに迷路を歩くのは大変です。第10弾前編と同じく、EndingTrigger と推理の台を一時的に入口の近くへ移す手があります。確認が済んだら、必ず戻してください。


この章のまとめ

  • 結末の分岐は「条件を満たしたボタンだけ存在させる」の1か所に集める

  • 隠しの選択肢は、灰色で見せない。存在そのものがネタバレになる

  • 「引き返す」を付け、トリガーは Exit で再武装する

  • 演出で下げた値は、関所が戻す。関所を通らない経路は、自分で戻す


第6章 次の周回に残す(前回の結末・記念碑・はじめから)

主旨

結末が3つになったので、「前回どの結末だったか」に意味が生まれました。第10弾前編で保存だけしていた LastEnding を、ここで初めて読み出します。タイトル画面に前回の結末を出し、入口に前回の探索者の名を刻み、「はじめから」を作ります。

■ おさらい:3つの置き場所

第10弾前編の「シーンに置いたものは勝手にリセットされる。生き残らせたものは自分でリセットする」に、もう1行足します。残したいものは、保存して読む。

■ 目玉つまずき:前回の結末が、エディターでだけおかしい
LastEnding は static です。第1章の📝で触れたとおり、6.6 の新規プロジェクトの既定設定では、static の値が前回の再生から残ります。すると「Main を直接再生し直したら、タイトルに前回のプレイの結末が出る」といった、エディターでしか起きない混乱が起きます。

だから、タイトル画面と記念碑は static ではなく、保存した値(第5章で足した SavedLastEnding)を読みます。Ending シーンだけは、同じセッション内で Main から来るので static のままです。


タイトル画面に、前回の結末を出す

※ イメージ図

■ UIを作る(Titleシーン)
【Titleシーン】を開きます。

  1. Canvas を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 RecordText。日本語フォント。アンカー middle center、位置 X:0 / Y:70、幅 700/高さ 40、Font Size 20、Alignment は横・縦とも中央、Vertex Color を薄い黄(例:R:255 G:220 B:120)。文字は仮で「前回の結末:―」

  2. Canvas を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前 NewGameButton。アンカー middle center、位置 X:0 / Y:-220、幅 240/高さ 56。子の Text は「はじめから」、日本語フォント、Font Size 22。Navigation None、UiClickSound

📝 「はじめる」(Y:-60)と「おわる」(Y:-140)の下に置きます。ボタンが4つになって縦に長くなりました。画面の比率によって寄って見える問題の根本の対策(Canvas Scaler の設定)は、第13弾で扱います。

■ TitleMenu.cs(改修後・全体)

// ==========================================================
// 📄 スクリプト:TitleMenu.cs(第11弾後編・改修後・全体)
// ==========================================================
using UnityEngine;
using UnityEngine.UI;   // 💡 後編で追加:Image を使う
using TMPro;

// タイトル画面の進行役。名前と前回の結末の表示、3つのボタンの行き先を担当します。
public class TitleMenu : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI welcomeText;   // 「ようこそ」の表示先

    [Header("周回の記録(後編)")]
    [SerializeField] private TextMeshProUGUI recordText;    // 💡 後編で追加:RecordText
    [SerializeField] private GameObject newGameButton;      // 💡 後編で追加:NewGameButton(クリア後だけ出す)
    [SerializeField] private TextMeshProUGUI newGameLabel;  // 💡 後編で追加:NewGameButton の子の Text
    [SerializeField] private Image background;              // 💡 後編で追加:TitleBackground(結末で色を変える)
    [SerializeField] private float confirmSeconds = 3f;     // 「はじめから」の確認に与える秒数

    private bool  confirming;    // 「はじめから」を1回押した状態
    private float confirmUntil;  // この時刻を過ぎたら、確認を取り消す

    private void Start()
    {
        // 第6弾の伏線回収:冒険者名簿に刻んだ名前を読む
        string name = PlayerPrefs.GetString("PlayerName", "");
        welcomeText.text = string.IsNullOrEmpty(name)
            ? "ようこそ、名もなき冒険者"
            : $"ようこそ、{name} さん";

        // 💡 後編で追加:前回の結末を、保存した値から読む(static の LastEnding は読まない)
        var gm = GameManager.Instance;
        if (gm.HasSavedEnding)
        {
            var ending = gm.SavedLastEnding;
            recordText.text = $"前回の結末:{GameManager.EndingLabel(ending)} (探索 {gm.LapCount} 回)";
            recordText.gameObject.SetActive(true);
            background.color = ColorFor(ending);   // タイトルの色が、結末で変わる
        }
        else
        {
            recordText.gameObject.SetActive(false);
        }

        // 💡 後編で追加:「はじめから」は、一度クリアした人にだけ見せる
        newGameButton.SetActive(gm.HasClearedOnce);
        newGameLabel.text = "はじめから";
    }

    private void Update()
    {
        // 💡 後編で追加:確認の時間切れ。何秒か放っておくと、元の文字に戻る
        if (confirming && Time.time > confirmUntil)
        {
            confirming = false;
            newGameLabel.text = "はじめから";
        }
    }

    // 「はじめる」ボタンから呼ばれる
    public void OnClickStart()
    {
        GameManager.Instance.ResetRunState();
        ScreenFader.Instance.FadeAndLoad("Main");
    }

    // 💡 後編で追加:「はじめから」ボタンから呼ばれる(2回押しで確定)
    public void OnClickNewGame()
    {
        if (!confirming)
        {
            // 1回目:本当に消してよいか、文字で確認する
            confirming = true;
            confirmUntil = Time.time + confirmSeconds;
            newGameLabel.text = "もう一度押すと消えます";
            return;
        }

        // 2回目:進行の記録を、持ち主ごとに消す(名前と音量は残る)
        GameManager.Instance.ResetProgress();      // 秘宝・周回数・クリア済み・結末
        ClueJournal.Instance?.ResetProgress();     // 手がかり・推理
        OneShotFlag.Clear("IntroPlayed");          // 導入演出(前編)をもう一度見せる
        OneShotFlag.Clear("LapNoticeShown");       // 周回の案内(この章)も戻す

        GameManager.Instance.ResetRunState();
        ScreenFader.Instance.FadeAndLoad("Main");
    }

    // 「おわる」ボタンから呼ばれる
    public void OnClickQuit()
    {
#if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
#else
        Application.Quit();
#endif
    }

    // 💡 後編で追加:結末ごとのタイトルの色(switch式)
    private static Color ColorFor(GameManager.EndingType ending) => ending switch
    {
        GameManager.EndingType.Treasure => new Color32(60, 50, 20, 255),   // 暗い金
        GameManager.EndingType.Truth    => new Color32(40, 20, 60, 255),   // 暗い紫
        _                               => new Color32(24, 28, 44, 255),   // 第10弾前編の暗い青
    };
}

※ Assets/Scripts内の TitleMenu.cs を上書き保存します。

ヒエラルキーの TitleMenu(空オブジェクト)を選択し、割り当てます。

⚠️ TitleMenu のスクリプトは、第10弾前編からこのオブジェクトに付いています。上書き保存した時点で、付いているコンポーネントに「周回の記録(後編)」の欄が増えているので、そこに割り当てます。「コンポーネントを追加」でもう一度付けると、TitleMenu が2つになり、再生したときに空の欄でエラーになります(「はじめる」「おわる」のボタンは、もとからある方に結線されています)。

  • Record Text:RecordText

  • New Game Button:NewGameButton

  • New Game Label:NewGameButton の子の Text (TMP)

  • Background:TitleBackground(第10弾前編のパネル)

  • Confirm Seconds:3

📝 日本語エディターでは、「Background」の欄は「背景」と表示されます(第4章の「キャンバスグループ」「行」と同じく、欄の名前の一部が日本語に訳されます)。

続いて、NewGameButton を結線します(第5章の結末ボタンと同じ手順です)。

  1. ヒエラルキーで NewGameButton を選択

  2. Button の「クリック時 ()」の「+」→ オブジェクト欄に TitleMenu(第10弾前編で作った空オブジェクト)をドラッグ

  3. 「No Function」をクリック → 「TitleMenu」のサブメニュー → OnClickNewGame () を選ぶ

最後に Cmd + S で Title シーンを保存します。

■ コードの読みどころ

  • 消すのは、持ち主ごとに。 秘宝と周回は GameManager、手がかりと推理は ClueJournal、一度きりの印は OneShotFlag。それぞれが自分のキーだけを消します。PlayerPrefs.DeleteAll() を1行書けば全部消えますが、名前(第6弾)と音量(第10弾後編)まで消えてしまいます。「何を残し、何を消すか」を持ち主に決めさせる作りです。

  • 確認は「2回押し」。 確認ダイアログを作るとUIが1枚増えます。ボタンの文字を変えて、3秒以内にもう一度押したら実行、放っておけば戻る。部品を増やさずに事故を防げます。

  • 「はじめから」は、クリアした人にだけ出します。 初回の人には意味が無い上に、間違って押すと導入をもう一度見ることになるからです。

📝 「はじめから」で IntroPlayed も消しています。導入演出(前編)は「その遺跡を初めて訪れたとき」のものなので、記録を消して最初からやり直すなら、もう一度流れるのが自然です。


入口に、前回の探索者の名を刻む(記念碑)

2周目以降だけ、入口の広間に記念碑が現れます。前回の探索者の名前と結末が刻まれています。「周回で追加のオブジェクトを出す」と「前回の名前の反映」を、1つのオブジェクトでまとめてやります。

■ 記念碑を置く(Mainシーン)

MemoryStone

ヒエラルキーの何も無いところで右クリック ▸ 空のオブジェクトを作成(何かの子にしない)。名前を MemoryStone に。位置は入口の広間の北東の角(例:(5, 0, 4.5)。石碑・手記・名簿・宝箱と離れていれば、環境に合わせて変えて構いません)、回転 (0, 0, 0)、スケール (1, 1, 1)

MemoryStone - Base

MemoryStone を右クリック ▸ 3D オブジェクト ▸ キューブ。名前 Base。位置 (0, 0.6, 0)、スケール (1.0, 1.2, 0.3)。マテリアルは、第7弾で宝箱の本体に使った ChestMat(Assets/Materials)を流用します。レイヤーは Default のまま(調べる対象にはしません)

MemoryStone - MemoryText
  1. MemoryStone を右クリック ▸ 3D オブジェクト ▸ テキスト - TextMeshPro。名前 MemoryText。位置 (0, 0.85, -0.16)、回転 (0, 0, 0)、スケール (0.05, 0.05, 0.05)。Rect Transform の幅 20/高さ 6(台の正面と同じ 1.0m × 0.3m)。日本語フォント、Font Size 18、Alignment は横・縦とも中央、Vertex Color は白(既定)のまま(台の色の上で読みにくければ、変えてください)、文字は仮で「前回の探索者」

  2. 台が床に埋まったり浮いたりしたら、親の MemoryStone の位置 Y だけを床に合わせる(子の数値は変えない)

  3. 文字は南を向いています(台の正面は Z がマイナスの側。位置 Z の -0.16 は、正面〔-0.15〕の1cm手前です)。広間のプレイヤーから見て鏡文字なら、MemoryStone(親)の回転 Y を 180 にして、台ごと向きを変えてください

📝 幅を 1.0m にしたのは、「前回の探索者:〇〇」と結末の2行を収めるためです。Font Size 18 は、スケール 0.05 のとき1文字がおよそ 9cm で、1行に10文字ほど入ります。名前が長くて折り返す場合は、Font Size を少し下げてください。


■ LapMemorial.cs(新規)

// ==========================================================
// 📄 スクリプト:LapMemorial.cs(新規)
// ==========================================================
using UnityEngine;
using TMPro;

// 2周目以降にだけ現れる記念碑。前回の探索者の名前と結末を刻む。
// 空の親(MemoryStone)にアタッチします。
public class LapMemorial : MonoBehaviour
{
    [SerializeField] private TextMeshPro memoryText;   // MemoryText(3D の TMP)

    private const string NoticeKey = "LapNoticeShown"; // 一度きりの案内の印(前編の OneShotFlag)

    private void Start()
    {
        var gm = GameManager.Instance;

        // 初回の探索では、姿を消す(シーンに置いたものは、次に読み直したとき新品に戻る)
        if (gm == null || gm.LapCount < 1)
        {
            gameObject.SetActive(false);
            return;
        }

        string name = PlayerPrefs.GetString("PlayerName", "名もなき冒険者");
        memoryText.text = $"前回の探索者:{name}\n{GameManager.EndingLabel(gm.SavedLastEnding)}";

        // 2周目の最初に一度だけ、記念碑のことを知らせる(前編の「一度きりの印」の再利用)
        if (!OneShotFlag.IsDone(NoticeKey))
        {
            JudgeFeedbackUI.Instance?.ShowMessage("前回の探索の記録が、入口に刻まれている");
            OneShotFlag.MarkDone(NoticeKey);
        }
    }
}

Assets/Scripts内に LapMemorial.cs を作成。

MemoryStone にアタッチ。Memory Text 欄に MemoryText を割り当てます。

📝 SetActive(false) を自分自身にかけています。Start の途中で自分を非アクティブにしても、その Start はそのまま最後まで走り(ここでは return するだけ)、次のフレームから姿が消えます。シーンを読み直せば、また Start から始まる(=1周目なら消え、2周目なら出る)ので、「周回で出す」に必要なのはこの1行だけです。


動作確認

【Bootシーン】から再生します。

  • 1周目:タイトルに「前回の結末」は出ず、「はじめから」も無い。入口に記念碑は無い

  • 1周クリアして「タイトルへ」→ タイトルに「前回の結末:〇〇(探索 1 回)」が出て、背景の色が結末に合わせて変わっている。「はじめから」が現れている

  • 「はじめる」→ Main に入ると、入口に記念碑があり、前回の名前と結末が刻まれている。案内の一言は、このときだけ出る

  • 別の結末でクリアすると、タイトルの文字と色が変わる

  • 「はじめから」を1回押すと文字が変わり、3秒放置で戻る。3秒以内に2回目を押すと、Main が最初から始まる:導入演出が流れ、宝箱が閉じ、手がかりが「0 / 8」、記念碑が無い。名前と音量はそのまま

  • 6.6 で追試している方:Main を直接再生し直しても、タイトルの表示が前回のプレイの値に引きずられない(保存した値を読んでいるため)

確認の一例として、「真相を告げる」の結末で1周したあとの画面を載せておきます。

1周クリアしてタイトルに戻ったところ。「前回の結末:真相にたどり着いた (探索 1 回)」が表示され、背景が真相の結末の色(暗い紫)に変わっています。一度クリアしたので、「はじめから」のボタンも現れました。

「はじめる」で2周目に入ると、入口の広間に記念碑が現れ、前回の探索者の名前と結末が刻まれています。1周目には無かったもので、周回数が1以上のときだけ姿を見せます。

📝 ブラウザ版への予告。第10弾前編のコラムで、ゲームを再アップロードすると保存が読めなくなることがある、と触れました。「はじめから」を作ったので、プレイヤー自身が記録を消す手段は用意できました。上げ直しで読めなくなる件そのものは、第13弾の公開の章で扱います。


この章のまとめ

  • 残したいものは、保存して読む。static は同じセッション内の受け渡しにだけ使う

  • 消すのは持ち主ごと。DeleteAll ではなく、自分のキーだけを DeleteKey

  • シーンに置いたものは新品に戻る。「周回で出す」は Start で自分を消すだけ

  • 一度きりの印は、消せる手段とセットで作る(「はじめから」で戻す)


おわりに

今回、実装したもの

後編で実装したもの(新規8本・改修6本)── 部品の地図
  • 手がかりの台帳 ClueData(ScriptableObject の2回目。id はアセット名に頼らない。OnValidate で入力チェック)

  • 読むたびに続きが出る石碑と、翻訳を「取り寄せて待つ」書き方(ブラウザ版の安全策)

  • 収集帳 ClueJournal(HashSet + PlayerPrefs の「1本の文字列」+ C# の event)

  • 受け取り手が2つ(カウンターHUD・通知)。繋いだら外す

  • 推理の板と推理ボード(集めた語だけを候補に、3つまとめて判定、ヒントは「あと何個ちがう」)

  • 結末を選ぶパネルと3種類のエンディング。前編の「静けさ」の初使用と、関所の答え合わせ

  • 前回の結末の読み出し、タイトルの変化、記念碑、「はじめから」


今回の学び

5つの学び
  1. 繋いだら、外す。 C# の event は「コードで配線する放送」。発行元がシーンをまたいで生き残るほど、外し忘れが事故になる。Start/OnDestroy か OnEnable/OnDisable の対で。

  2. 翻訳は取り寄せ。 その場で返してもらう書き方は、ブラウザ版で止まる。伝票(AsyncOperationHandle)を待ってから読む。常に見せる文言は「届いたら教えて」にする。

  3. 判定は1か所に。 ボードの正誤も、結末の分岐も、条件を見るのは1つのメソッドだけ。増やすときに触る場所が1つで済む。

  4. 消すのは持ち主。残すのは保存して読む。 DeleteAll を避け、自分のキーだけを消す。static は同じセッション内の受け渡しにだけ使う。

  5. 1行を丁寧に、残りは複製。 3行のボードも、3つの結末ボタンも、1つ目で Navigation・音・フォントまで済ませてから増やす。


コラム:Timeline を使わなかった理由

Timeline とコルーチンの比較表

前編のおわりに、なぜ Timeline を使わなかったのかの答え合わせを後編の最後に書く、と予告しました。

Unity には、演出専用の Timeline という道具があります。時間軸の上にカメラ・アニメーション・音を並べ、目で見ながら調整できます。決まった尺の演出を、複数のオブジェクトで同期させるなら、Timeline は強力です。Unity 6.5 では Timeline はパッケージ(1.8.13)で、6.6 からはエディターと一体になりました。

Timelineコルーチン(本連載)得意決まった尺の演出。複数のオブジェクトを同じ時間軸で動かす。目で見て調整ゲームの状態で分岐する演出。短い演出途中で分岐苦手(Signal で外に知らせて、外で分岐)得意(if を書くだけ)数値の調整時間軸の上でドラッグインスペクターの数値止めるPlayableDirector を操作StopCoroutine/bool の旗

この連載の演出は、ほとんどが「ゲームの状態」で変わります。秘宝の数で結末のボタンが変わり、推理を当てたかで隠しの道が開き、初回かどうかで導入が流れる。同じ筋書きを毎回同じように見せるなら Timeline、プレイの結果で筋書きが変わるならコルーチン。結末が3つに分かれるこの回で、コルーチンを選び続けたのはそのためです。

📝 もう1つ、Unity 6 には await で待つ書き方(Awaitable)もあります。コルーチンの中から使うこともできます。この連載では登場させませんでしたが、コルーチンに慣れたあとの次の一歩として、名前だけ覚えておいてください。


第12弾予告

次回は『見た目を仕上げる — 3Dアセットの探し方・ライセンス・組み込み』です。

第3弾から使い続けてきたプリミティブの遺跡を、本物の3Dアセットに差し替えます。通路や広間は ProBuilder のままテクスチャを差し替え、宝箱・石碑・番人といった置き物だけをモデルにします。前編で触れた、石碑や扉の文字を差し替えやすい形に整え直す件も、そこで行います。

そして第13弾で、この遺跡を itch.io に公開します。第10弾後編で第13弾に回した、ブラウザで音が鳴らない問題の答え合わせも、そこで。

最後まで読んでいただき、ありがとうございました。次回もあたたかい目で見守っていただけると嬉しいです。

いいなと思ったら応援しよう!

辛島信芳 ここまで読んでくれてありがとうございます!記事は基本的に無料で出していくので、気が向いたら応援してもらえると嬉しいです☕