見出し画像

【Unity】タイトル画面とセーブを実装する(Build Profiles・フェード遷移・ポーズ・ビットフラグ保存)


第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弾:『演出と結末 — コルーチン演出・推理ボード・マルチエンディング』

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

第10弾は、内容量の都合で前編・後編に分けました。連載の弾番号と大枠のテーマ(ゲームの骨格 — シーン・セーブ・音)は変わりません。前編が「シーンとセーブ」、後編が「音とミニマップ」です。

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

みなさんこんにちは。連載「初級から中級への架け橋」シリーズ、今回で第10弾(前編)です

第9弾までで、ゲームの「中身」はほぼ揃いました。迷いの回廊があり、真贋の宝箱があり、番人ゴーレムが巡回している。……でも、Unityの再生ボタン「▶」を押すと、いきなり遺跡のド真ん中から始まります。タイトル画面がない。終わりもない。せっかく集めた秘宝も、再生を止めれば消えてしまう。

つまり、まだ「1本のゲーム」の形をしていないのです。

今回はこの「骨格」を入れます。Boot → Title → Main → Ending の4シーン構成にして、黒いフェードで滑らかにつなぎ、Escキーでポーズできるようにして、集めた秘宝をビットフラグでセーブする。エンディングにたどり着けば周回数が増え、2周目を始めても宝箱は開いたまま。「遊び終えて、また遊べる」ゲームの器を作ります。

そしてもうひとつ。第7弾のGameManagerで「DontDestroyOnLoadは、必要になってから足すのがシングルトンと安全に付き合うコツ」と書きました。シーンが4つになった今こそ、その"必要になったとき"です。予告どおり、ここで回収します。


今回の到達点

今回の到達点(第10弾・前編)
  • ゲームが Boot → タイトル → 本編 → エンディング の4シーン構成になり、Build Profilesに登録される

  • タイトル画面に「ようこそ、◯◯さん」(第6弾で名簿に刻んだ名前)が表示される

  • すべてのシーン移動が、黒いフェードで滑らかにつながる

  • Escキーでポーズ(つづける/タイトルへ/おわる)。既存のゲームスクリプトは1行も改修しません

  • 秘宝がビットフラグでセーブされ、Unityを再起動しても宝箱が開いたまま

  • エンディング到達で周回数が増え、2周目はフェーズが浅層に戻る(でも秘宝は残る)

  • GameManagerがDontDestroyOnLoadの完成形になる(第7弾の予告回収)

👩‍💻 こんな方に向けた内容です
・Unity 6で「Build Settingsが見つからない!」と探し回った方
・シーンを分けた途端、管理オブジェクトが重複して困った方
・Time.timeScale = 0 にしたら「フェード演出まで止まった」経験のある方
・PlayerPrefsで「3つの収集フラグ」をどう保存するか迷っている方
・第1〜9弾から続けて作ってくださっている方

🛠️ 動作検証環境
・macOS Tahoe 26.5.1
・Unity 6.5(6000.5.5f1)+ URP 17.5.0
・Input System 1.20.0(第5弾で自作した GameControls + Generate C# Class 方式)
・Cinemachine 3.1.7 / UniVRM 0.131.2(VRM0.xのVRoidアバター model_test)
・ProBuilder 6.1.2 / Unity Localization 1.5.12 / AI Navigation 2.0.14
・エディタは日本語化(メニュー名は日本語表記+必要に応じて英語併記)

※ 本記事は第10弾の前編です。音(AudioMixer・足音・3D定位)とミニマップは後編で扱います。


本記事の構成

本記事は「シーンを分ける → 旅路をつなぐ → 入口を作る → 時を止める → 記憶を残す → 終わりと始まり」という6段階で進みます(※「第1章 はじめに」を除く)。章ごとに必ず動く状態で区切ってあるので、1章ずつ動作を確かめながら進めてください。

第2章「シーンを4つに分ける」 今まで1つだったシーンをMainに改名し、Boot・Title・Endingを新規作成。Unity 6で刷新されたBuild Profilesにシーンリストを登録します。「Lighting設定はシーンごと」という重要な仕様もここで。

第3章「フェードでシーンをつなぐ — 連載初のDontDestroyOnLoad」 黒フェードでシーンを渡り歩く ScreenFader を作ります。シーンをまたいで生き残る DontDestroyOnLoad の初登場。「タイトルに戻ったら操作できない」という定番バグを、構造で予防する仕掛けもここに入れます。

第4章「タイトル画面を作る」 UI構築を1操作ずつ。第6弾で記帳した名前が「ようこそ」と出迎えます。ボタンとメソッドをインスペクターで繋ぐ方式も初登場です。

第5章「ポーズ — Escで時を止める」 Time.timeScale = 0 の「止まるもの/止まらないもの」を正確に整理します。実は本作、第6弾で作ったUiGateのおかげで、ポーズが新規スクリプト1本で完成します。

第6章「セーブ — 秘宝を周回に残す」 今回の心臓部。GameManagerをDontDestroyOnLoadの完成形に育て、秘宝3つをint 1個のビットフラグで保存します。itch.io(Web公開)特有のセーブの罠も先取りします。

第7章「エンディングと周回」 宝物庫の奥にゴールを置き、エンディングシーンへ。周回数が刻まれ、「はじめる」で2周目へ。ゲームがついに1本の輪になります。

それでは始めましょう!


第2章 シーンを4つに分ける

今のシーンを「Main」に改名する

ゲームを構成する4つのシーン(今回作るもの)

まず、これまで第1弾から育ててきたシーン(迷路も番人も全部入っているもの)を、本編シーン「Main」として位置づけ直します。

  1. 現在のシーンを開いた状態で、ファイル ▸ 保存(Cmd + S)で保存しておきます。

  2. Projectウィンドウで Assets 直下に Scenes フォルダを作成します(右クリック ▸ 作成 ▸ フォルダ)。すでにある方はそのままで。

  3. 現在のシーンファイル(Example_0001 など)を Scenes フォルダへドラッグして移動します。※ シーンと同名のフォルダが隣にあれば、それも一緒に移動してください。

  4. シーンファイルをクリックして選択し、もう一度クリック(またはリターンキー)で名前を Main に変更します。同名フォルダも Main にリネームしておくと、後々すっきりします。

  • シーンと同名のフォルダの正体
    Unityが自動生成したベイクデータの置き場です。第4弾のリフレクションプローブとVolumeプロファイル、第9弾のNavMesh、ライティングのベイク結果が入っています。消すと番人が歩けなくなるので、シーンとセットで扱ってください。リネームしなくても動きますが(参照はGUID管理のため)、焼き直すと新しい名前のフォルダが作られて紛らわしくなります。

📝 Unity内での移動・改名なら参照は自動で追従します(第6弾でマテリアルを移動したときと同じ理屈です)。※ Finderで直接動かすのはNGです。

⚠️ この章から先はシーン構成を大きく触ります。第8弾と同じく、作業前にシーンファイルの複製バックアップ(Cmd + D で Main_backup など)か、UVCS/Gitでのチェックインをおすすめします。


新しいシーンを3つ作る(Boot・Title・Ending)

  1. ファイル ▸ 新しいシーン(Cmd + N)を選びます。

  2. テンプレート選択のダイアログが開くので、Basic (URP) を選んで「作成(Create)」。

  1. Main Camera と Directional Light だけが入った、まっさらなシーンが開きます。

  2. Cmd + S で保存。保存先を Assets/Scenes、ファイル名を Title にします。

  3. 同じ手順をあと2回繰り返し、Boot と Ending も作ります。

📝 テンプレートの選び方
一覧には「Basic (URP)」「Indoors (URP)」「Outdoors (URP)」「Empty」「Basic (Built-in / Deprecated)」 が並びます。Basic (URP) はカメラとディレクショナルライトだけの最小構成で、今回の3シーンにはこれが最適です。Empty はカメラすら無いので「No Cameras Rendering」になりますし、Built-in版は非推奨(第2弾で触れた古い描画システム用)なので選ばないでください。


Build Profilesにシーンを登録する

作った4シーンを、ビルド(と、シーン間の行き来)の対象としてUnityに教えます。ここがUnity 6で大きく変わった場所です。

  1. ファイル ▸ ビルドプロファイル(Build Profiles)を開きます。

  2. ウィンドウ左側の一覧から「シーンリスト(Scene List)」を選びます。 ※ 見当たらない場合は、左のプラットフォーム名(macOS など)を選んだ先にシーンリストの欄があります。ウィンドウ構成はマイナーバージョンで少し動きますが、要は「Scene List」という一覧に4つ並べばOKです。

  3. ProjectウィンドウのAssets/Scenesから、Boot をシーンリストへドラッグ&ドロップします。続けて Title、Main、Ending も同様に。

  4. リスト内をドラッグして、上から Boot → Title → Main → Ending の順に並べ替えます。

  5. 各シーンの左端のチェックボックスがすべてオンになっていることを確認します。

この並び順が、そのまま「ビルドインデックス」(右端の番号、0始まり)になります。そしてインデックス0のシーン=ゲーム起動時に最初に読み込まれるシーンです。


■ 動作確認

  • Assets/Scenes に Boot / Title / Main / Ending の4ファイルが並んでいる

  • ビルドプロファイルのシーンリストに、この順で4つ登録されている(Bootが0番)

  • Main をダブルクリックで開けば、今までどおり遺跡が遊べる


第3章 フェードでシーンをつなぐ — 連載初のDontDestroyOnLoad

DontDestroyOnLoadとは

基本概念:シーン切り替え時の生存

シーンを切り替えると、元のシーンにあったオブジェクトはすべて破棄されます。これがUnityの大原則です。でも、フェード用の黒い幕が「Titleに移動した瞬間に消える」のでは、フェードが完成しません。幕には、シーンをまたいで生き残ってもらう必要があります。

そのための仕組みが DontDestroyOnLoad です。これを呼ばれたオブジェクトは、シーンをいくつまたいでも破棄されなくなります。押さえておくべき公式仕様は3つです。

■ DontDestroyOnLoadの3つの仕様

押さえておくべき3つの公式仕様
  1. ルートのGameObjectにしか効きません。 何かの子オブジェクトに対して呼ぶと、警告が出て無視されます(子はどうせ親ごと生き残るので、親に呼べばOK)。

  2. 再生中のヒエラルキーを見ると、対象は「DontDestroyOnLoad」という特別なシーン欄に引っ越します。「あれ、消えた?」と思ったら、ヒエラルキーの一番下を見てください。

  3. 破棄されないということは、同じシーンに戻ってくると2体になるということです。ここが最大の落とし穴で、対策があの「Awakeのシングルトンガード」(第4弾から書き続けてきた保険)です。今回、あの保険がついに本当の仕事をします。


フェード用のUIを作る(Bootシーンで)

Bootシーンを開いて、『幕(画面を覆う黒いUI)』を作ります。1操作ずつ行きましょう。

■ FaderCanvas

  1. ヒエラルキーで右クリック ▸ UI (Canvas) ▸ キャンバス(Canvas)。名前を FaderCanvas に変更します。

  2. FaderCanvas を選択し、インスペクターの Canvas コンポーネントを確認します。

    • レンダーモード:スクリーンスペース - オーバーレイ(既定のまま)

    • ソート順:999 に変更

  3. FaderCanvas に「コンポーネントを追加」▸ Canvas Group を追加します(第7弾のJudgePanelで使った、あの3点セットです)。

📝 同じ「スクリーンスペース - オーバーレイ」のCanvas同士では、この並び順序の数字が大きいほど手前に描画されます。フェードの幕は、ポーズメニューだろうがHUDだろうが、何よりも手前に来てほしいので、大きな数字にしておきます。

■ FadeImage(FaderCanvasの中に作成)

  1. FaderCanvas を右クリック ▸ UI (Canvas) ▸ 画像(Image)。名前を FadeImage に。

  2. FadeImage の Rect Transform で、アンカープリセットからストレッチ(全方向)を選び、Left / Right / Top / Bottom をすべて 0 に(画面全体を覆う板になります)。

  3. Image の 色(Color)を黒、アルファ(A)を 255 にします。


ScreenFader.cs(新規・シーン遷移の関所)

// ==========================================================
// 📄 スクリプト:ScreenFader.cs
// ==========================================================
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement; // シーンの読み込みに必要

// 黒いフェードでシーンをつなぐ管理役。
// 連載初の「DontDestroyOnLoad」で、シーンをまたいで生き残ります。
public class ScreenFader : MonoBehaviour
{
    // どこからでも呼べるようにするシングルトン参照(第4弾からの型)
    public static ScreenFader Instance { get; private set; }

    [SerializeField] private CanvasGroup canvasGroup;   // FaderCanvasのCanvasGroup
    [SerializeField] private float fadeDuration = 0.6f; // 片道のフェード秒数

    private bool isBusy;              // 遷移中の多重呼び出しを防ぐフラグ
    private Coroutine autoFadeRoutine; // 起動時の明転コルーチン(多重起動防止用に覚えておく)

    private void Awake()
    {
        // シングルトンの基本形。ここが今回、初めて「本当に」働きます。
        // シーンを行き来してBootに戻ったとき、後から生まれた自分を消すための保険です。
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;

        // 💡 連載初登場:シーンが切り替わっても破棄されなくなる
        DontDestroyOnLoad(gameObject);

        // 起動直後は真っ黒から始める(ゲームは暗闇から明けるのが気持ちいい)
        canvasGroup.alpha = 1f;
        canvasGroup.blocksRaycasts = true; // 明け切るまでクリックを通さない
    }

    private void Start()
    {
        // 黒からふわっと明ける。
        // これが無いと、Mainシーンを単体で再生したとき画面が真っ黒のままになります
        // (Awakeでalpha=1にしたきり、誰も明転させる人がいないため)。
        autoFadeRoutine = StartCoroutine(Fade(1f, 0f));
    }

    // 外部から呼ぶ入口:「黒くする → シーンを読む → 明ける」を一括で行う
    public void FadeAndLoad(string sceneName)
    {
        if (isBusy) return; // 遷移中の連打を無視

        // 💡 多重起動防止(第4弾の定石)。
        // 起動直後の明転(Start)がまだ走っている最中にFadeAndLoadが呼ばれると、
        // 「0へ向かうコルーチン」と「1へ向かうコルーチン」が同じalphaを奪い合い、
        // 画面がチカチカします。新しく始める前に、走っている方を必ず止めます。
        if (autoFadeRoutine != null)
        {
            StopCoroutine(autoFadeRoutine);
            autoFadeRoutine = null;
        }

        StartCoroutine(FadeAndLoadRoutine(sceneName));
    }

    private IEnumerator FadeAndLoadRoutine(string sceneName)
    {
        isBusy = true;
        canvasGroup.blocksRaycasts = true; // 幕の下のボタンを押せなくする

        // ① 暗転(今の明るさから黒へ)
        yield return Fade(canvasGroup.alpha, 1f);

        // ② 💡「後片付け3点セット」——どの遷移でも必ず通る関所
        //    ポーズ中(timeScale=0)から「タイトルへ」で抜けても、
        //    ここで必ず時間・音・UIゲートが正常に戻ります。
        //    「タイトルに戻ったら全部止まってる」という定番バグの構造的な予防です。
        Time.timeScale = 1f;          // 時間を通常に戻す
        AudioListener.pause = false;  // 音の一時停止を解除
        UiGate.IsOpen = false;        // UIゲートを開ける(第6弾のstaticフラグ)

        
        // FPS中のロックを解除(残るとボタンが押せない)
        Cursor.lockState = CursorLockMode.None; // カーソルのロックを解除
        Cursor.visible = true;                  // カーソルを表示

        // ③ 新しいシーンを非同期で読み込む
        AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
        while (!op.isDone)
        {
            yield return null; // 読み終わるまで待つ(画面は真っ黒のまま)
        }

        // ④ 明転(黒から透明へ)
        yield return Fade(1f, 0f);

        canvasGroup.blocksRaycasts = false; // クリックを通す
        isBusy = false;
    }

    // alphaをfromからtoへ動かす。第4弾のLerp骨格そのもの……ですが1点だけ違います。
    private IEnumerator Fade(float from, float to)
    {
        float time = 0f;
        while (time < fadeDuration)
        {
            // 💡 deltaTimeではなく「unscaledDeltaTime」を使います。
            // Time.timeScale = 0(ポーズ中)でもdeltaTimeと違って0にならないため、
            // 止まった世界の上でもフェードだけは進みます(詳しくは第5章)。
            time += Time.unscaledDeltaTime;
            canvasGroup.alpha = Mathf.Lerp(from, to, time / fadeDuration);
            yield return null;
        }
        canvasGroup.alpha = to; // 誤差をなくすため最後にきっちり
        canvasGroup.blocksRaycasts = to > 0.99f; // 💡 追加:黒いときだけ遮り、明けたら通す
    }
}


Assets/Scripts内に ScreenFader.cs を作成し、FaderCanvas にアタッチします。

インスペクターの キャンバスグループ 欄に、FaderCanvas 自身をドラッグします(付いているCanvas Groupが入ります)。

■ なぜ StopCoroutine が要るのか

なぜ StopCoroutine が要るのか?(1つの値を動かすのは1本だけ)

明転(黒→透明)が終わる前に暗転(透明→黒)が始まると、2本のコルーチンが同じ alpha を取り合って画面がチラつきます。第4弾のFogControllerと同じ「走っている方を止めてから始める」定石です。ひとつの値を動かすコルーチンは、常に1本だけと覚えてください。

■ コラム

allowSceneActivation = false の罠

ローディング画面を作りたくなったら LoadSceneAsync には allowSceneActivation という設定があり、falseにすると読み込みが終わってもシーンを切り替えず待機できます。「NOW LOADING」を見せてから切り替えたいとき用です。

⚠️ ただしfalseの間は進捗が0.9で止まり isDone もtrueになりません(待ち続けると無限ループ)。本作は軽いので使いません。


BootLoader.cs(新規・出発係)

// ==========================================================
// 📄 スクリプト:BootLoader.cs
// ==========================================================
using System.Collections;
using UnityEngine;

// Bootシーンの仕事はひとつだけ:管理役たちを起こして、タイトルへ出発すること。
public class BootLoader : MonoBehaviour
{
    IEnumerator Start()
    {
        // 1フレーム待つ:同じシーンにいる全員のAwake/Startが済んでから出発する、
        // という小さな行儀です(管理役の初期化とすれ違わないため)。
        yield return null;
        ScreenFader.Instance.FadeAndLoad("Title");
    }
}

■ アタッチ

ヒエラルキー(Bootシーン)で右クリック ▸ 空のオブジェクトを作成 ▸ 名前を BootLoader にして、アタッチします。

■ 動作確認

Bootシーンを開いてゲーム開始「▶」。

  • 真っ黒な画面からふわっと明るくなり、Titleシーン(空と地平線だけ)に切り替わる

  • ヒエラルキーの一番下に「DontDestroyOnLoad」欄が現れ、FaderCanvasがそこに引っ越している

  • コンソールにエラーが出ていない

📝 Titleはまだ空っぽなので、空が見えるだけで正解です。次章で入口を作ります。


第4章 タイトル画面を作る

UIを組み立てる(1操作ずつ)

Titleシーンを開きます。第6弾・第7弾で鍛えたUI構築の総復習です。

① Canvasと背景

  1. ヒエラルキーで右クリック ▸ UI (Canvas) ▸ パネル。Canvasごと自動生成されます(EventSystemも一緒に生成されます。各シーン1つの方針どおり、そのまま置いておきます)。

  2. パネルの名前を TitleBackground に。生成時から全面ストレッチなのでサイズはそのまま。

  3. Image の色を、遺跡の夜明け前のような暗い青(例:R:24 G:28 B:44、A:255)に。

② タイトルロゴ

  1. Canvas を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前を GameTitleText に。

  2. Font Asset に NotoSansJP-Medium SDF を割り当て(合言葉:忘れると豆腐)。

  3. テキストは仮で「遺跡と秘宝(仮)」。正式なタイトルは第12弾の公開時に決めます。

  4. アンカープリセット:top center、位置Y -160、幅 800 / 高さ 120、Font Size 64、Alignment:中央揃え(横・縦)。

③ ようこそテキスト(第6弾の伏線回収)

  1. 同様に テキスト - TextMeshPro を作成。名前を WelcomeText に。日本語フォントを割り当て。

  2. アンカー:middle center、位置Y 20、幅 600 / 高さ 50、Font Size 24、中央揃え。

  3. テキストは仮で「ようこそ」(実行時にスクリプトが上書きします)。

📝 タイトル画面に「ようこそ、◯◯さん」と出迎える。遺跡の奥で刻んだ名前が、次に起動したときタイトル画面で自分を呼んでくれるという体験になります。

④ ボタン2つ

  1. Canvas を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro。名前を StartButton に。

  2. アンカー:middle center、位置Y -60、幅 240 / 高さ 56。

  3. 子の Text (TMP) を選び、文字を「はじめる」、日本語フォント割り当て、Font Size 24。

  4. 同様にもう1つ作成。名前 QuitButton、位置Y -140、文字「おわる」。


TitleMenu.cs(新規)

// ==========================================================
// 📄 スクリプト:TitleMenu.cs
// ==========================================================
using UnityEngine;
using TMPro;

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

    private void Start()
    {
        // 💡 第6弾の伏線回収:冒険者名簿に刻んだ名前(キー "PlayerName")を読む。
        // 第2引数は「まだ記帳していないとき」に返る値です。
        string name = PlayerPrefs.GetString("PlayerName", "");
        welcomeText.text = string.IsNullOrEmpty(name)
            ? "ようこそ、名もなき冒険者"
            : $"ようこそ、{name} さん";
    }

    // 「はじめる」ボタンから呼ばれる
    public void OnClickStart()
    {
        // ⏳【第6章で有効化】周回の開始時に、フェーズと失敗数をリセットします。
        // いま有効化すると、ResetRunState がまだ存在しないため
        // error CS1061 になります。第6章でGameManagerを完成させてから外します。
        // GameManager.Instance.ResetRunState();

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

    // 「おわる」ボタンから呼ばれる
    public void OnClickQuit()
    {
#if UNITY_EDITOR
        // エディタでは Application.Quit が「無視される」のが公式仕様なので、
        // 代わりに再生を停止します。
        UnityEditor.EditorApplication.isPlaying = false;
#else
        Application.Quit(); // ビルドではアプリを終了
#endif
    }
}

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

■ プリプロセッサディレクティブ

「 #if 」は普通のif文とは異なり、プログラムを作る(コンパイルする)段階で不要な行を消し去る仕組みです。

『 #if UNITY_EDITOR 〜 #else 〜 #endif 』は連載初登場の書き方で、プリプロセッサディレクティブと呼びます。

決定的な違い:普通の「if」との比較

「エディタで動かすときはこちらの行、ビルドではこちらの行」というように、コンパイルの段階で行そのものを切り替える仕組みです。if文と違って、使われない側の行はビルドに含まれすらしません。

💡 UnityEditor ネームスペース(エディタ操作用の命令群)はビルドに含めることができず、混ざるとコンパイルエラーになります。#if を使うことで、このエラーを確実に回避しつつ、エディタでのテストを快適に行えます。

ヒエラルキーで右クリック ▸ 空のオブジェクトを作成 ▸ 名前を TitleMenu にしてアタッチし、Welcome Text 欄に WelcomeText をドラッグします。

⚠️ 「おわる」ボタンとWeb(ブラウザ)の相性 公式仕様として、Application.Quit はエディタでは無視され、Web(WebGL)ではゲームが止まるだけでブラウザのページはそのままです。つまりitch.ioのブラウザ版では「おわる」を押すと画面が固まったように見えるだけ。第12弾のWeb公開時に、このボタンをWeb版でだけ隠す対応をします(今日は伏線として置いておきます)。


ボタンとメソッドを「インスペクターで」繋ぐ

第6弾のNoteUIでは、コードから onClick.AddListener() で繋ぎました。今回はもうひとつの定番、インスペクターで繋ぐ方式を覚えます。

■ StartButton

  1. ヒエラルキーで StartButton を選択します。

  2. インスペクターの Button コンポーネントにある「クリック時 ()(On Click ())」欄の「+」を押します。

  3. 追加された行の左下のオブジェクト欄(None)に、ヒエラルキーの TitleMenu をドラッグします。

  4. 右のドロップダウン「No Function」をクリック ▸ TitleMenu ▸ OnClickStart () を選びます。

■ QuitButton

※ 同様に QuitButton にも、TitleMenu ▸ OnClickQuit () を割り当てます。

■ どちらの方式を使うべき?

インスペクター方式は「コードを触らず繋げる・見た目で分かる」のが利点、AddListener方式は「繋がりがコードに残るので追跡しやすい」のが利点です。本連載では、パーツが少なく固定的なメニュー画面はインスペクター方式、パーツが多いUIはAddListener方式、と使い分けていきます。


■ 動作確認
Bootシーン
から「▶」。

  • 黒から明けてタイトル画面。「ようこそ、◯◯さん」に第6弾で記帳した名前が出る(未記帳なら「名もなき冒険者」)

  • 「はじめる」→ フェードでMainへ。遺跡がいつもどおり遊べる

  • 「おわる」→ エディタの再生が止まる

  • ⏳のコメントを外していないこと(外すとCS1061でコンパイルが止まります)

名前を保存したものが

タイトル画面にも反映

📝 Mainに入ったあと、タイトルへ戻る手段がまだありません。それが次章のポーズです。


第5章 ポーズ — Escで時を止める

Time.timeScale = 0 で「止まるもの/止まらないもの」

ポーズの定番は Time.timeScale = 0f です。ただしこれ、全部が止まるわけではありません。公式仕様を正確に押さえましょう。

■ 止まるもの

  • 物理(FixedUpdateは呼ばれなくなります)

  • Time.deltaTime(0を返すようになる)→ deltaTimeで動くもの全部(移動・タイマー・霧のフェード)

  • コルーチンの WaitForSeconds(進まなくなる)

  • Animator(既定設定のまま)、NavMeshAgentの移動

■ 止まらないもの

  • Update / LateUpdate の呼び出し自体(呼ばれるが、deltaTimeが0なだけ)

  • Time.unscaledDeltaTime / unscaledTime(timeScaleの影響を受けない時間)

  • コルーチンの WaitForSecondsRealtime

  • Input Systemの入力検知、uGUIのボタンクリック

📝 だから第3章のScreenFaderは unscaledDeltaTime で書いたのです。ポーズ(timeScale=0)の最中に「タイトルへ」を押しても、止まった世界の上でフェードだけは実時間で進みます。もしdeltaTimeで書いていたら、暗転が永遠に終わりません。


なぜ本作のポーズは「新規1本」で済むのか(第6弾の大回収)

普通、ポーズを後付けすると「移動スクリプトに判定を足して、敵AIに判定を足して……」と改修の嵐になります。ところが本作は、既存スクリプトを1行も触りません。棚卸ししてみましょう。

  • プレイヤーの入力系(移動・ランタン・視点切替・調べる)→ 第6弾の UiGate.IsOpen で既に一括停止できる作りです

  • 番人ゴーレム → Updateは回り続けますが、Time.deltaTime が0なので検知ゲージも巡回も一切進みません(改修ゼロで無害化)

  • 霧・テレポート演出(WaitForSecondsのコルーチン)→ timeScale=0で凍結。むしろ望みどおり

  • Cinemachineのカメラ → CinemachineBrainには「Ignore Time Scale」という設定があり、既定はオフ=カメラもtimeScaleに従って静止します。ポーズ中にカメラが動かないのは正常挙動です

  • FPSのマウスルック → CameraModeSwitcherのUpdate冒頭にUiGateの早期returnが入っています(第6弾)→ 視点も止まる

つまり、第6弾で作った1個のstatic bool(UiGate)が、ポーズ機能の8割を先に作ってくれていたのです。残りは「Escを監視して、ゲートと時間を開け閉めする係」を1本書くだけ。設計の貯金が効く、気持ちのいい回です。


Pauseアクションを追加する(GameControls)

Escキーを、第5弾で自作した GameControls に登録します。

  1. Projectウィンドウで GameControls をダブルクリックし、単体のInput Actionsウィンドウで開きます。

  2. Action Maps「Player」を選択 → 中央 Actions の「+」→ 名前を Pause に。右側の Action Properties で Action Type を Button に。

  3. Pause の左の「▶」を展開 → <No Binding> の行をクリック → 右側 Binding Properties の Path ドロップダウンを開く → 検索欄に escape と入力 → Keyboard ▸ Escape を選択。

  4. 『Save Asset』を必ず押す(合言葉です)。

  5. Save Assetを押した時点で、生成クラス GameControls.cs も自動的に作り直されています。 Projectウィンドウで Assets/GameControls.cs を開き、「Pause」で検索して確認しておくと安心です。

📝 Escapeは「文字キー」ではないため、第4弾で使った By Character Mapped to Key の一覧には出てきません。L や F のような文字キーは配列差対策でBy Characterを使いましたが、Escape・Space・Shiftのような機能キーは、今回のように通常のKeyboardパスで指定します。


ポーズメニューのUIを作る

Mainシーンを開いて作業します。

※ スクショ用にPausePanel以外は一時的に隠してます
  1. Canvas(第5弾から使っているもの)を右クリック ▸ UI (Canvas) ▸ パネル。名前を PausePanel に。

  2. PausePanel は生成時から全面ストレッチです。そのまま、Image の色を黒・アルファ 180(半透明の暗幕)に。

  3. PausePanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 PauseTitle。

    • 日本語フォント割り当て、文字「ポーズ」、Font Size 40、中央揃え

    • アンカー:top center、位置Y -120、幅 300 / 高さ 60

  4. PausePanel を右クリック ▸ UI (Canvas) ▸ ボタン - TextMeshPro を3つ作ります。

    • ResumeButton:アンカー middle center、位置Y 40、幅 240 / 高さ 56。子Textは「つづける」(日本語フォント・Size 24)

    • ToTitleButton:位置Y -40、同サイズ。「タイトルへ」

    • PauseQuitButton:位置Y -120、同サイズ。「おわる」


PauseMenu.cs(新規)

// ==========================================================
// 📄 スクリプト:PauseMenu.cs
// ==========================================================
using UnityEngine;
using UnityEngine.InputSystem;

// Escキーでゲームを一時停止する管理役。
// パネル自身ではなく、常時アクティブな空オブジェクトにアタッチします(理由は本文)。
public class PauseMenu : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;              // PausePanel
    [SerializeField] private CameraModeSwitcher cameraSwitcher; // カーソル復帰用(第6弾の口)

    private GameControls controls;
    private InputAction pauseAction;
    private bool isPaused;

    private void Awake()
    {
        controls = new GameControls();
        pauseAction = controls.Player.Pause; // 型安全に取得(第5弾方式)
        panelRoot.SetActive(false);          // 最初は閉じておく
    }

    private void OnEnable()  { controls.Player.Enable(); }
    private void OnDisable() { controls.Player.Disable(); }

    private void Update()
    {
        if (!pauseAction.WasPressedThisFrame()) return;

        // 刻文や名簿など「別のUI」が開いている間は、ポーズを重ねて開かない。
        // (isPaused中は自分がUiGateを閉めている張本人なので、例外的に通します)
        if (!isPaused && UiGate.IsOpen) return;

        if (isPaused) Close();
        else          Open();
    }

    private void Open()
    {
        isPaused = true;
        UiGate.IsOpen = true;          // 入力系を一括停止(第6弾の仕組み)
        Time.timeScale = 0f;           // 世界の時間を止める
        AudioListener.pause = true;    // 音もまとめて一時停止(後編でUI音の例外を作ります)
        Cursor.lockState = CursorLockMode.None; // ボタンを押せるようカーソル解放
        Cursor.visible = true;
        panelRoot.SetActive(true);
    }

    private void Close()
    {
        panelRoot.SetActive(false);
        Time.timeScale = 1f;           // 時間を戻す
        AudioListener.pause = false;   // 音を戻す
        UiGate.IsOpen = false;         // ゲートを開ける
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode(); // FPSならロック/TPSなら解放(第6弾の口)
        isPaused = false;
    }

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

    public void OnClickResume() => Close();

    public void OnClickToTitle()
    {
        // 💡 ここで Close() を呼ばずに直行してOKです。
        // フェーダーの「後片付け3点セット」(第3章)が、暗転の裏で
        // timeScale・音・UiGate を必ず正常に戻してくれるからです。
        // 「戻し忘れ」がそもそも起きない構造、というわけです。
        ScreenFader.Instance.FadeAndLoad("Title");
    }

    public void OnClickQuit()
    {
#if UNITY_EDITOR
        // エディタでは Application.Quit が「無視される」公式仕様のため、再生停止で代替
        UnityEditor.EditorApplication.isPlaying = false;
#else
        Application.Quit(); // ビルドではアプリ終了(Webでは効きません:第4章⚠️参照)
#endif
    }
}

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

なお、 AudioListener.pause = true で、再生中のすべての音が止まります(第4弾の環境音も含めて)。「ポーズ中もメニュー操作音だけは鳴らす」例外設定(ignoreListenerPause)もありますが、まだ操作音が無いので後編で扱います。

■ アタッチと結線(アタッチ先が重要です)
ヒエラルキー(Mainシーン)で右クリック ▸ 空のオブジェクトを作成。名前を PauseMenu にして(FogControllerやGameManagerの隣が見つけやすいです)、PauseMenu.cs をアタッチします。

  1. Panel Root 欄に PausePanel をドラッグ

  2. Camera Switcher 欄に アバター(model_test) をドラッグ(付いているCameraModeSwitcherが入ります)

⚠️  アタッチ先は、新しく作る空オブジェクト「PauseMenu」です
PausePanel自身にアタッチしてはいけません。非アクティブなGameObjectでは、Start も Update も呼ばれないからです。PausePanelは閉じている間 SetActive(false) なので、そこにPauseMenuを載せるとEscの監視が止まり、二度と開けなくなります。LoreUIをパネル自身に載せられたのは、開くきっかけ(石碑のInteract)が外から来る設計だったからです。


3つのボタンを、第4章と同じインスペクター方式で結線します

ResumeButton

ResumeButton の クリック時() ▸「+」▸ オブジェクト欄に PauseMenu ▸ 関数 OnClickResume ()

ToTitleButton

ToTitleButton ▸ 同様に OnClickToTitle ()

PauseQuitButton

PauseQuitButton ▸ 同様に OnClickQuit ()


■ 動作確認
【Bootシーン】から
「▶」

⚠️ Main単体で再生すると、DontDestroyOnLoadのScreenFaderが存在しないため「タイトルへ」でエラーになります。次章で両対応にします。そのため【Bootシーン】を開いて、そこで「▶」で開始してください。

  • Main到達後、Escで暗幕+「ポーズ」メニューが出る

  • ポーズ中:移動・ランタン・視点・番人・霧、すべて止まっている(検知ゲージも増えない)

  • 「つづける」またはEsc再押下で復帰。FPS中ならカーソルが再ロックされる

  • 刻文パネル(LoreUI)を開いている間はEscでポーズが開かない(UI競合の防止が効いている)

  • 「タイトルへ」→ フェードでTitleへ。タイトルのボタンが普通に押せる(timeScale=0の持ち越しが起きていない=関所の後片付けが効いた証拠)


第6章 セーブ — 秘宝を周回に残す

セーブするもの・しないものを決める(キー設計表)

PlayerPrefsは第6弾で「名前の記帳」に使いました。今回はゲーム進行を預けます。まず台帳から。

■ 保存しないもの
失敗数・フェーズ・番人の状態・霧の濃さ。これらは「その周だけの状態」なので、周回のたびにリセットされるのが正しい設計です。

📝 PlayerPrefsの保存先は、macOSなら ~/Library/Preferences/ のplist、WindowsならレジストリのHKCU配下、そしてWeb(ブラウザ)版ではIndexedDBに最大1MBまで——というのが公式仕様です。1MBは小さく感じますが、intとstringが数個の本作には十分すぎる広さです。


ビットフラグ — 秘宝3つをintの「1個」に入れる

秘宝は3つ。素直にやると Treasure1 Treasure2 Treasure3 とキーが3つ要ります。ここで使うのが『ビットフラグ』です。

ビットフラグの仕組み(int 1個で複数の状態を保存)

intの中身は32個の「電球(ビット)」の列だと思ってください。右から0番・1番・2番……と番号を振り、秘宝Jnを取ったらn番の電球を点ける、と決めます。

・何も無い:000 → 0
・J1(0番)だけ:001 → 1
・J1とJ3(0番と2番):101 → 5

3つの取得状況が、intたった1個で表せました。操作はC#の定石3点セットです。

flags |= (1 << n);           // n番の電球を「点ける」(ORで合成)
flags &= ~(1 << n);          // n番の電球を「消す」(反転してANDで削る)
bool on = (flags & (1 << n)) != 0; // n番が点いているか「確かめる」

1 << n は「1をnマス左へずらす」演算で、n番の電球だけが点いた数(1, 2, 4, 8…)を作ります。intなら電球は32個——ただし最上位の31番は符号に使われるので、実用は0〜30番の31個までと覚えておけば安全です。秘宝3つには余裕たっぷりですね。


GameManager.cs 完成形(DontDestroyOnLoad化・全掲載)

第7弾で「必要になってから足す」と約束したDontDestroyOnLoadを、ついに足します。改修が多いので全体を載せます(※ 『💡』が今回の追加・変更です、またエラー前提となります)。

// ==========================================================
// 📄 スクリプト:GameManager.cs(第10弾・完成形)
// ==========================================================
using UnityEngine;

// ゲーム全体の進行状態を1か所で預かる管理役(完成形)。
// 💡 今回からシーンをまたいで生き残ります(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 }

    // 💡 エンディングの種類。今回はNormalの1つだけ(3種に増えるのは第11弾)
    public enum EndingType { Normal = 0 }

    // 💡 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; }

    // 💡 変更:秘宝の数は、フラグから「点いている電球」を数えて算出します。
    // (PhaseDebugHUDは今までどおりTreasureCountを読むだけ。改修不要です)
    public int TreasureCount
    {
        get
        {
            int count = 0;
            for (int i = 0; i < 3; i++)                    // 秘宝は3つ(0〜2番)
                if ((TreasureFlags & (1 << i)) != 0) count++; // 点いていたら+1
            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    => "深層",
        _ => "不明"
    };

    private void Awake()
    {
        // シングルトンガード(第4弾からの型)。
        // Boot経由でMainに入ると、Mainに置いてある2体目がここで自滅します。
        // 第3章で予告した「保険が本当の仕事をする」瞬間です。
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;

        // 💡 シーンをまたいで生き残る(第7弾の予告回収)
        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;
    }

    // 💡 追加:n番の秘宝を持っているか(宝箱の復元判定に使います)
    public bool HasTreasure(int index) => (TreasureFlags & (1 << index)) != 0;

    // 💡 変更:旧AddTreasure()の後継。n番の電球を点けて、その場で保存します
    public void CollectTreasure(int index)
    {
        TreasureFlags |= (1 << index); // ビットを立てる
        PlayerPrefs.SetInt(KeyTreasure, TreasureFlags);
        PlayerPrefs.Save(); // 💡 セーブ点では必ず明示保存(規律。理由は本文の🧩)
    }

    // 💡 追加:エンディング到達の記録(EndingTriggerから呼ばれます)
    public void NotifyEndingReached(EndingType type)
    {
        LastEnding = type;             // static伝言板に書く(Endingシーンが読む)
        LapCount++;                    // 周回数を刻む
        HasClearedOnce = true;
        PlayerPrefs.SetInt(KeyLap, LapCount);
        PlayerPrefs.SetInt(KeyCleared, 1);
        PlayerPrefs.SetInt(KeyEnding, (int)type);
        PlayerPrefs.Save();
    }

    // 💡 追加:周回の開始準備。「その周だけの状態」をリセットします。
    // DontDestroyOnLoad化した副作用で、フェーズが2周目に持ち越されてしまうため、
    // タイトルの「はじめる」で必ずこれを通します(詳しくは第7章)。
    public void ResetRunState()
    {
        currentPhase = DepthPhase.Shallow;
        MistakeCount = 0;
    }
}

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

⚠️ 保存した瞬間、コンソールが赤くなります(想定内です)
 
第8弾の AddTreasure() を廃止して CollectTreasure(int) に置き換えたので、これを呼んでいる ChestPairController が「そんなメソッドは無い」= error CS1061 を出します。この先の「ChestPairControllerを復元対応に改修する」まで進めば消えるので、慌てないでください。第8弾でFogアクションを消したときと同じ、型安全のご利益(消し忘れをコンパイラが教えてくれる)です。エラーが出ている間は再生できないので、続けて改修まで一気に進めましょう。

  • LastEnding のキー(保存だけして、まだ読み出していない点)について
    Awakeで読み込んでいるのは秘宝・周回数・クリア済みの3つで、LastEnding は書き込むだけにしています。今回のエンディングは1種類しかなく、読み戻す意味がないためです。第11弾でエンディングが3種類に増えたとき、「前回どのエンドを見たか」をタイトル画面に表示する等でここを読み出します。先にキーだけ確保しておくのは、あとからセーブ形式を変えずに済ませるための地味なコツです。

  • なぜ毎回 PlayerPrefs.Save() を呼ぶのか
    公式仕様では、アプリが正常終了するときに自動保存されます。しかしクラッシュ時は保存されませんし、Web版ではもっと切実です。ブラウザには「アプリを閉じた」を確実に知る手段がなく、タブを閉じた瞬間の自動保存は間に合わないことがあります。だから「秘宝を取った」「エンディングに着いた」という失いたくない瞬間ごとに、明示的にSave()。第6弾で決めたこの規律を、そのまま続けます。


管理役は「BootとMainの両方」に置く

BootシーンにGameManagerとFaderCanvasを置きたい。でもMainシーン単体でも開発テストしたい——両立させます。

  1. 【Mainシーン】の GameManager を、Assets/Prefabs へドラッグしてPrefab化します(シーンには残したまま)

  2. 【Bootシーン】を開き、GameManagerのPrefabを配置します(FaderCanvasは第3章で配置済み)

  3. 【Bootシーン】の第3章で作った FaderCanvas も同様にPrefab化します(※ BootLoaderはPrefab化しません。あれはBoot専用の出発係です)

  4. 【Mainシーン】を開き、FaderCanvasのPrefabを配置します

これで、どちらのシーンから再生しても動くようになりました。Bootから始めればBoot側の管理役が生き残り、Main側の同じものは「すでに居るので」自動で消えます(Awakeの保険が働く場面です)。Mainから始めれば、Main側がそのまま管理役になります。

💡 開発中は毎回タイトル画面を経由しなくてよくなるので、動作確認が速くなります。

■ 再生してよいシーン・いけないシーン
管理役(GameManager・ScreenFader)を置いたのは Boot と Main の2つだけです。したがって

  • Boot から再生(可能)
    本番と同じ流れ。通しの確認向き

  • Main から再生(可能)
    遺跡の中からすぐ始まる。開発中の常用。ポーズ→タイトル→はじめる、と一巡もできます(管理役がMainからDDoL化して生き残るため)

  • Title / Ending を直接開いて再生(不可)
    管理役が居ないので、ボタンを押した瞬間に NullReferenceException(ScreenFader.Instance や GameManager.Instance が null)になります

Title / Endingにも管理役のPrefabを置けば単体再生できるようになりますが、「4シーン全部に管理役を置く」と配置忘れや二重管理の元になります。本連載は入口を2つに絞る方針でいきます。UIの見た目だけ調整したいときは、再生せずシーンビューで整えれば十分です。

⚠️ Prefab化したあと、シーンに置いたインスタンス側で値を変えると、その項目が太字(オーバーライド)で表示されます。第8弾の宝箱ペアと同じで、これは正常な状態です。「Prefabに適用」は押さないでください(押すと、その値が全インスタンスに広がります)。


ChestPairController を「復元対応」に改修する

第8弾で予告した「集めた秘宝が次の周回に残る」を実装します。変更は4点、既存の抽選・判定ロジックには触れません。

① フィールド追加(junctionId の下あたり)

    // 💡 追加:この分岐の秘宝が使う「電球の番号」(J1=0, J2=1, J3=2)
    [SerializeField] private int treasureIndex;

② Start を差し替え(復元チェックを先に通す)

    private void Start()
    {
        // 💡 追加:セーブに「取得済み」とあれば、抽選せず開いた姿を復元する
        if (GameManager.Instance.HasTreasure(treasureIndex))
        {
            RestoreAsSolved();
            return;
        }
        SetupFromDirector();
    }

③ RestoreAsSolved() を新設(SetupFromDirectorの下あたり)

    // 💡 追加:前の周で解決済みだった姿(開いたランドマーク)を再現する
    private void RestoreAsSolved()
    {
        IsSolved = true;
        chestA.ResetView(basePlateText);   // 差分なしの姿に整えてから
        chestB.ResetView(basePlateText);

        // correctSide(A/B固定)から正解の箱を割り出して開けておく
        TreasureChest correctChest = (correctSide == CorrectSide.A) ? chestA : chestB;
        correctChest.Open();

        chestA.SetExaminable(false);       // もう調べられない(第8弾の卒業処理)
        chestB.SetExaminable(false);
        MazeDirector.Instance.MarkSolved(junctionId); // 司令塔の帳簿にも記帳
    }

④ Judge() の正解処理内、1行差し替え

// 変更前:GameManager.Instance.AddTreasure();
GameManager.Instance.CollectTreasure(treasureIndex); // 💡 差し替え

■ シーン側の設定

ヒエラルキーで3体のChestPairをそれぞれ選択し、インスペクターに現れた Treasure Index に番号を入力します。junctionId が J1 なら 0、J2 なら 1、J3 なら 2 です。

ChestPairは第8弾でPrefab化してあるので、この値はインスタンスごとの上書き(オーバーライド)になり、項目名が太字で表示されます。これが正常です。「Prefabに適用」は押さないでください——押すと3体すべてのTreasure Indexが同じ番号になり、「1つ取ったら3つとも開いた」という珍事が起きます。

これらの「番号」はプログラムから自動で振ることもできますが、セーブデータの番号は一度決めたら永久に変えられないという性質があります(0番=分岐①、と決めた後で順番を入れ替えると、既存プレイヤーの「取得済み」がズレます)。だから自動生成に任せず、人間の目で見える形でインスペクターに固定(人間の手で番号を振る)するのが、セーブ絡みの設計では安全です。

📝 復元はcorrectSideがA/B固定であることに頼っています。第8弾で3体すべて固定にしたので本作は問題ありませんが、もしRandomのペアを作った場合、その分岐だけは復元非対応(毎周やり直し)になります。


仕上げ:タイトルの⏳を有効化

TitleMenu.cs の OnClickStart 内、コメントアウトしていた1行を有効化します。

GameManager.Instance.ResetRunState(); // ⏳→✅ 有効化

■ 動作確認

  • Bootから再生 → Mainで秘宝を1つ取る → 再生停止 → もう一度再生 → その宝箱が最初から開いていて、調べられず、HUDが「秘宝: 1 / 3」

  • 記帳をやり直したいときは 編集 ▸ すべての PlayerPrefs を消去(第6弾の手順)で全セーブが消え、初回状態に戻る


【先取りコラム】 itch.io(Web版)だけの、セーブが消える罠

Web版のセーブ先IndexedDBは、「どのURLから起動したか」ごとに別の引き出しになります。そしてitch.ioは、ゲームを再アップロードするたびに配信URLの内部フォルダ(ハッシュ)を変える仕様だと運営自身が明言しています。つまり「v1.1に差し替えたら、プレイヤー全員のセーブが(消えてはいないのに)読めない引き出しに取り残される」ことが起きます。エディタやWindows版では絶対に再現しない、公開して初めて刺さる罠です。対策込みで第12弾のitch.io公開の章で正面から扱います。今日は「Webのセーブは引っ越しに弱い」とだけ覚えておいてください。

※ 予定です。「itch.io公開」は違う弾になるかも知れません。


第7章 エンディングと周回 — ゲームが輪になる

ゴール(EndingTrigger)を置く

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

// 宝物庫の最奥。触れたら探索終了、エンディングへ。
public class EndingTrigger : MonoBehaviour
{
    private bool fired; // フェード中の多重発火を防ぐ

    private void OnTriggerEnter(Collider other)
    {
        if (fired) return;
        // CharacterControllerを持つ=プレイヤー(連載の作法)
        if (other.GetComponent<CharacterController>() == null) return;
        fired = true;

        // 記録(周回数++・クリア済み・エンド種別→static伝言板)してから旅立つ
        GameManager.Instance.NotifyEndingReached(GameManager.EndingType.Normal);
        ScreenFader.Instance.FadeAndLoad("Ending");
    }
}

■ シーン構築(Main)

空オブジェクト EndingTrigger を宝物庫の最奥、位置 (4, 1.2, 68) に作成(※環境の床高さに読み替えてください)。Box Collider を追加し Is Trigger をオン、Size 4 / 2.5 / 1(奥壁の手前を横切る関所)。レイヤーはDefaultのまま。EndingTrigger.cs をアタッチします。


■ 目印に「出口の光」を置く

トリガーは透明なので、このままだと何もない壁の前で唐突にエンディングが始まります。「奥に何かある」と分かる目印を置いておきましょう。

■ 発光マテリアル

  • Materialsフォルダで LanternGlowMat を選択 → Cmd + D で複製

  • 名前を ExitGlowMat に変更

  • 放出マップの色を、やや白っぽい淡い色(例:R:80 G:190 B:160)に

  • HDR強度を 1.0 程度に(今は1〜2.5のはず)

■ 光る面

  1. ヒエラルキーで右クリック ▸ 3Dオブジェクト ▸ クアッド(Quad)。名前を ExitGlow に

  2. 位置を (4, 2.55, 69.4)(トリガーより奥の壁ぎわ、目線より少し上)、スケールを (3, 3, 1) に

  3. 回転Yを調整して、プレイヤーが歩いてくる側(-Z)を向くようにします(板が見えなければ180度回してください)

  4. 付いている Mesh Collider を削除します(当たり判定は不要で、あるとレイや移動の邪魔になります)

  5. マテリアル ExitGlowMat をドラッグして適用

暗い回廊の奥に、ぼんやりと光る面が浮かびます。プレイヤーは自然とそこへ向かい、光に触れた瞬間にエンディングへ——という流れになります。


秘宝が3つ揃っていなくても、到達すれば終わります(第8弾の「秘宝は扉の条件ではない」仕様のまま)。「集めた数でエンディングが変わる」仕掛けは、第11弾のマルチエンディングで結線します。

💡 テストのたびに迷路を全部歩くのは大変です
動作確認のあいだだけ、EndingTriggerを入口の近く(例:直線Aの中ほど、Z:10 付近)へ一時的に移動させると、数秒でエンディングまで通せます。確認が済んだら忘れずに宝物庫の奥へ戻してください。同じ手で、第8弾のDepthTrigger(フェーズ昇格)も手前に寄せておくと「深層のエンディング」まで一気に検証できます。位置を戻し忘れたままビルドするのがいちばんありがちな事故なので、戻す作業をワンセットにするのがおすすめです。


エンディング画面を作る

【Endingシーン】を開いてUIを組みます。タイトル画面の弟分なので、手早く行きましょう。

  1. UI (Canvas) ▸ パネル → 名前 EndingBackground、色は黒(A:255)

  2. テキスト - TextMeshPro → 名前 EndTitleText。日本語フォント、文字「― 探索完了 ―」、Font Size 56、中央揃え。アンカー top center、位置Y -180、幅 700/高さ 100

  3. テキスト - TextMeshPro → 名前 ResultText。日本語フォント、Font Size 26、中央揃え。アンカー middle center、位置Y 0、幅 700/高さ 120。文字は仮のまま(実行時に上書き)

  4. ボタン - TextMeshPro → 名前 EndToTitleButton。アンカー bottom center、位置Y 120、幅 240/高さ 56。子Textは「タイトルへ」(日本語フォント・Size 24)

■ EndingMenu

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

// エンディング画面。結果を表示して、タイトルへ送り返します。
public class EndingMenu : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI resultText;

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

        // 💡 static伝言板(LastEnding)はここで読みます。今回はNormal1種なので
        // 文言は共通ですが、第11弾で「どのエンドか」による分岐がここに入ります。
        resultText.text =
            $"{name} は、{gm.LapCount} 周目の探索を終えた。\n" +
            $"手にした秘宝:{gm.TreasureCount} / 3";
    }

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

■ アタッチ

空オブジェクト EndingMenu を作ってアタッチし、Result Text 欄に ResultText 

EndToTitleButton の クリック時() に EndingMenu ▸ OnClickToTitle () を割り当てます。

2周目が「深層」から始まらないように

ここで、DontDestroyOnLoadの副作用がひとつ現れます。

GameManagerは生き残るので、何もしないと2周目も「深層」のまま始まってしまうのです。1周目で到達したフェーズを覚えたままだからですね。これを浅層へ巻き戻すのが、「はじめる」で呼ぶ ResetRunState() です。第6章で仕込んだ1行が、ここで効きます。

逆に、シーンに置いたDepthTrigger(第8弾でSetActive(false)にしたもの)は、Mainシーンを読み直せば自動で元に戻ります。

シーンに置いたものは勝手にリセットされる。生き残らせたものは自分でリセットする。 この住み分けが、シーン設計の基本です。


総合動作確認(周回一巡テスト)

  1. Bootから再生 → タイトル「ようこそ、◯◯さん」→ はじめる

  2. 迷路を攻略し、宝物庫の最奥へ → フェード → 「― 探索完了 ―」「1 周目」「秘宝: ◯ / 3」(※ ゲーム進行状況による)

  3. タイトルへ → はじめる(2周目)→ HUDが浅層に戻っている/宝箱の開きは前のまま/DepthTriggerは再び有効(歩けば中層→深層に上がる)

  4. 再生を完全に停止してBootから再生し直しても、周回数と秘宝が残っている

  5. Escポーズ→「タイトルへ」→「はじめる」でも、時間・音・操作がすべて正常(関所の後片付けが効いている)

すべて通れば、ゲームはついに「始まり、終わり、また始まる」1本の輪になりました。


おわりに

■ 今回、実装したもの

  • Boot → Title → Main → Ending の4シーン構成と、Build Profiles(Unity 6の新しいシーン登録場所)

  • 連載初の DontDestroyOnLoad(ScreenFader・GameManager)と、Awakeガードが本当に働く瞬間

  • unscaledDeltaTimeで書くフェード遷移+後片付けを一手に引き受ける「遷移の関所」(timeScaleやカーソルの戻し忘れバグを構造的に予防)

  • 既存スクリプト無改修のポーズ(第6弾UiGateの大回収)

  • ビットフラグによる秘宝セーブと、宝箱の周回復元(第8弾予告の回収)

  • 周回・クリア記録・エンド種別のstatic受け渡し(第11弾への布石)

  • 第6弾の積み残し「Startだと動かない理由」の回収

■ 後編予告

第10弾・後編は『ゲームに音を入れる — AudioMixer・足音・3D定位・ミニマップ』。第1弾からずっと空実装だった OnFootstep(足音)をついに回収し、第9弾で置いた📢(番人の状態遷移音・視線スイッチの点灯音)も鳴らします。番人の駆動音が3D定位で「音の気配」になり、暗い迷路の右上にミニマップが灯ります。

そして後編には、この連載でも指折りの衝撃つまずきが待っています。エディタでは完璧に鳴るのに、itch.ioのブラウザ版では鳴らない音がある——という話です。お楽しみに。

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

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