見出し画像

【Unity】迷いの回廊を作る(ループ迷路・シームレステレポート・導きの精霊)


第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弾:『番人ゴーレム — 巡回AIと、隠れて逃げるアクション』

  10. 📅 [執筆予定] 第10弾:『ゲームの骨格 — シーン・セーブ・音』

  11. 📅 [執筆予定] 第11弾:『演出と結末 — コルーチン演出・推理ボード・マルチエンディング』

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

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

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

前回の第7弾では、ScriptableObjectを使って「一対の宝箱のうち、片方だけが本物」という真贋ギミックを作りました。差分(Tell)を重み付き抽選で選び、コイントスでどちらの箱に仕込むかを決める——あの仕組みを、今回はいよいよ「迷路の分岐」に量産配置します。左右に分かれた道の突き当たりに宝箱のペアを置き、見分けを間違えると、霧にやさしく包まれて、気づけば少し手前に戻されている。そんな「迷いの回廊」を組み上げるのが今回のゴールです。

■ 今回の到達点

  1. 区画Prefabを連結した、ぐるりと巡れるループ回廊を実際に歩ける。

  2. 不正解の道に入ると、霧が満ちる→シームレスにテレポート→霧が引く、という一連の演出が流れる。

  3. テレポートしても、TPSカメラが世界を横断して吹っ飛んだりしない。

  4. 分岐を再訪すると、見分けポイントが引き直されることがある「記憶ギミック」。

  5. しばらく進めずにいると、導きの精霊が現れて正解ルートを教えてくれる(救済)。

  6. 奥へ進むと探索フェーズが自動で上がり、深層では判定が反転する。

  7. 正解の宝箱を開けると秘宝が集まる。

👩‍💻 こんな方に向けた内容です
・CharacterControllerでテレポートさせると、なぜか位置が元に戻ってしまう方。
・テレポートの瞬間にカメラが吹っ飛んでしまい、困っている方。
・Cinemachine 3系での「ワープ通知」の正しいやり方を知りたい方。
・Input Systemのアクション削除の手順に、いまひとつ自信が持てない方。
・ホラーではない、明るいトーンの「迷いの森」型ギミックを作ってみたい方。
・第1〜7弾から一緒に作り続けてくださっている方。

🛠️ 動作検証環境
・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 / エディタ日本語化


設計図:「迷いの森」を作る

「迷いの森」型のギミックは、ゲームの古典的な仕掛けです。初代『ゼルダの伝説』の迷いの森では、正しい方角の順路以外を選ぶと、同じ画面に戻されてやり直しになりました。『時のオカリナ』の迷いの森では、サリアの歌(BGM)の音量が大きくなる方向が正しい道、という音の手がかりが用意されていました。

ところで、この手の迷路ギミックの作り方は、大きく2つの型に分かれます。

  • 道を固定する型(ルート暗記型)
    何度やり直しても正解ルートは同じ。「少しずつ道を覚えて、進めるようになる」達成感を味わわせる作りです。初代ゼルダの迷いの森もこの型です。

  • 道を毎回変える型(ルール観察型)
    決まったルートは存在せず、「違和感があればこちら、なければあちら」というルールに従って進む作り。記憶よりも観察力を試します。

本作は前者(ルート固定型)です。正解の道はプレイのたびに変わりません。そのかわり、道を覚えるだけの作業にならないよう、観察の面白さは別の仕掛け〜宝箱の見分けポイントが化け直る「記憶ギミック」(第7章)〜で確保します。しかもこの化け直り、たくさん間違えて戻された人ほど出会いやすい作りになっています。つまり、観察をサボって間違えるほど丸暗記が通用しなくなる、という具合です(仕組みと確率のからくりは第7章で)。「道は覚えられる。でも手抜きはできない」が本作の設計方針です。

理不尽にしないための、3点セット

これらに共通するのは「理不尽にしない工夫」です。本連載では次の3点セットで理不尽を避けます。

  • 手がかり
    宝箱の真贋鑑定と、掟を記した石碑。

  • 後戻りの自由
    来た道を戻るのは完全に自由。観察もペナルティなし。

  • 無進捗の救済
    一定時間進めずにいると、導きの精霊が正解を示す。

そして今回の「戻される」は、けっしてペナルティではありません。「霧がやさしく包んで、気づけば少し手前にいる」という、やり直せる優しさとして演出します。


■ 今回のオリジナリティ(真贋鑑定 × 迷いの森 )

真贋鑑定 × 迷いの森

本作の框(かまち)組みは、「真贋鑑定型(本物と偽物で細部が違う、間違い探し)」と「迷いの森型(正しい道を選び続ける)」の融合です。真贋鑑定型の代表格として、あつまれ どうぶつの森の美術品鑑定(つねきちの売る美術品には本物と偽物があり、細部の違いで見分ける)がよく知られています。ただし、あちらは記憶頼みで見分ける必要があります。本作は一対の箱を並べて見比べられる、公平な間違い探しにしているのがポイントです。

📝 今回やらないこと
迷路の攻略状態(どの分岐を解いたか)のセーブは第10弾で、精霊の凝った演出は第11弾で扱います。今回はまず「動く回廊」を完成させます。


第2章 Corner_L区画を自作する(復習課題)

「L字」の区画

📝 第3弾の末尾で、迷路の連結は「第7弾で」とご案内していましたが、その後の連載構成の見直しにより、迷路は本弾(第8弾)で扱うことになりました。公開済みの記事は当時のまま残していますので、読み替えをお願いします。

迷路を組むには、まっすぐな通路とT字路だけでは足りません。90度に曲がる「L字」の区画が要ります。第3弾で作った区画の作り方を思い出しながら、Corner_Lを1つ自作しましょう。

メニューの「ゲームオブジェクト」→「ProBuilder」→「キューブ」、オブジェクト名は Corner_L に。インスペクターのPro Builder Shapeで、サイズを X:4 / Y:3 / Z:4 に設定します。

ツールコンテキストを『ProBuilder』に切り替え。面モードにします(左から3番目のボタン)。いずれかの面をダブルクリックすると全面が選択されます。

ProBuilder Actionsの「Flip Face Normals(面法線を反転)」を実行します。

📝 ProBuilder Actionsオーバーレイが見当たらない場合は、シーンビュー右上の「⋮」→「Overlays」→「Overlay Menu」から表示できます(第3弾と同じ手順です)。

隣接する2面(たとえば南面と東面)を選び、「Delete Faces(面を削除)」で開口にします。
Transformの位置を Y=1.5 にします(床合わせ。通路系は高さの半分)

📝  位置Yは「すでに置いてある通路やT字と同じ値」に合わせてください。 高さ3の半分=1.5が基本の計算ですが、床の高さが揃っていないと、連結したときに段差や隙間ができてしまいます。第3弾のあと微調整した方は、その値に読み替えてください(筆者の環境では1.55にしています)。

Assets/Prefabs へドラッグしてPrefab化します。

📝 ツールコンテキスト・Tool Settings・ProBuilder Actionsといったオーバーレイの出し方や役割そのものは、第3弾で詳しく解説済みです。ここでは手順の流れだけをおさらいしています。迷ったら第3弾を見返してください。なお、行き止まり区画が必要になったら「1面だけ開口」で同じ手順で作れます(今回は使いません)。


■ ゲーム開始「▶」で確認

L字といっても角の作成なので、実質は2面空いた四角です
  • 内側から覗いて、壁・床・天井がきちんと描画されていること(法線が内向き=Flip Face Normalsが効いている)。

  • 隣接する2面が抜けて、開口になっていること。

  • 外側から見ると壁が透けて中が見えること(内向き法線の正常な状態です。第3弾と同じ挙動)。

⚠️ 開口する2面が接する角では、稜線越しに外の景色が見えてしまう場合があります。これは、接続相手の区画が外側を塞ぐ前提でOKです。単体で眺めたときに気になっても、連結すれば見えなくなります。

確認できたら、シーン上のCorner_Lは削除して構いません。Prefab化した時点で、ヒエラルキーに残っているのは「Prefabのインスタンス(分身)」に変わっています(名前が青く表示されます)。


第3章 FogDemoToggleの撤去とFadeTo

仕様について

霧はなくなりません。制御権が移ります。

まず宣言しておきます。霧はなくなりません。制御権が移ります。

第4弾で霧を実装したとき、Fキーで霧を切り替えるFogDemoToggle.csには「動作確認用」と明記していました。第7弾でも「霧の制御は第8弾でゲーム側に渡す」と予告していました。その伏線を、いまここで回収します。これからは、霧はプレイヤーが自由にオン・オフするものではなく、ゲームが演出のために満たしたり引いたりするものになります。


作業の順序(この順で行うこと)

安全な削除の順序

まず「 FogDemoToggle.cs 」をProjectウィンドウで削除します。

アタッチ先だったFogControllerオブジェクトにMissing Scriptが残るので、インスペクターから掃除しておきます。

GameControlsアセットをダブルクリックして単体のInput Actionsウィンドウで開き、PlayerマップのFogアクションを削除します。
Save Asset を押して保存します。

📝 Save Assetを押すと、Unityがアセットを再インポートし、生成クラスGameControls.csが自動的に書き直されます。Assets/GameControls.cs を開いて「Fog」で検索し、該当する行が消えていることを確認しておくと安心です。


■ なぜこの順番なのか?

順番を逆にすると…

 順番を逆にして、先にアクションを削除→Applyしてしまうと、まだ残っているFogDemoToggle.csの中の _controls.Player.Fog が存在しないメンバーを参照することになり、error CS1061 でコンパイルが止まります。これは、生成クラス方式(Generate C# Class)の「型安全のご利益」です。消し忘れをコンパイラが即座に教えてくれるのです。もし第5弾以前のように文字列で FindAction("Fog") と書いていたら、無言でnullが返るだけで、実行するまで気づけませんでした。まずスクリプト側を消してからアクションを消す……この順番が安全です。


FogControllerを改修する

霧をゲームから細かく制御できるよう、FogController.csを改修します。今回は既存のメソッドを2つ書き換えるので、差分だけを載せるとかえって分かりにくくなります。改修後の全体コードを載せますので、FogController.csの中身をまるごと置き換えてください。変更したところには💡を付けています。

■ 改修の要点は6つ

霧をゲームから細かく制御できるよう、FogController.csを改修します。今回は既存のメソッドを2つ書き換えるので、差分だけを載せるとかえって分かりにくくなります。改修後の全体コードを載せますので、FogController.csの中身をまるごと置き換えてください。

■ 改修の要点は6つ

新設(新しく足す)
  • 【新設(フィールド)】 veilDensity(0.35)と veilFadeIn(0.7):テレポートの瞬間を隠す「視界のカーテン」の濃さと、その満ちる速さ。第5章の演出で使います。

  • 【新設】 FadeTo(float target, float duration = -1f):任意の濃さへ、任意の時間でフェードさせる。durationを省略(負の値)すると、これまでどおりfadeDuration(2秒)を使います。

  • 【新設】 FadeToMin(float duration = -1f):最小濃度(常時の薄霧)へ戻す便利メソッド。

  • 【新設】FadeToVeil():視界を覆うカーテンの濃さへ、素早くフェードさせる。第5章のテレポート演出はこれ1行を呼ぶだけになります。

書き換え(既存を置き換える)
  • 【書き換え】 FadeDensity:引数を「目標濃度」と「所要時間」の2つに変更します。既存の1引数版は残さず、この2引数版に置き換えてください(両方あると、古い方が使われ続けてしまいます)。

  • 【書き換え】ToggleFog():中身をFadeToへ委ねる形にします。多重起動防止(走っているコルーチンを止める処理)はFadeTo側へ移したので、ToggleFogからは消えます。

// ==========================================================
// 📄 スクリプト:FogController.cs(改修後・全体)
// ==========================================================
using System.Collections;
using UnityEngine;

// フォグの密度をコルーチンでなめらかに変化させる管理役。
// 空の GameObject(名前: FogController)にアタッチして使う。
public class FogController : MonoBehaviour
{
    // どこからでも呼べるようにするシングルトン参照
    public static FogController Instance { get; private set; }

    [SerializeField] private float minDensity = 0.005f;  // 霧が引いたときの薄さ(常時の薄霧)
    [SerializeField] private float maxDensity = 0.06f;   // 霧が満ちたときの濃さ
    // 💡 新設:テレポートの瞬間を隠す「視界のカーテン」の濃さ
    [SerializeField] private float veilDensity = 0.35f;
    [SerializeField] private float fadeDuration = 2.0f;  // フェードにかける既定の秒数
    // 💡 新設:カーテンが満ちるまでの秒数(通常より素早く覆う)
    [SerializeField] private float veilFadeIn = 0.7f;

    private Coroutine fadeRoutine; // 走行中のフェードを覚えておく(多重起動防止)
    private bool isThick = false;  // 現在「濃い側」かどうか

    void Awake()
    {
        // シングルトンの基本形:既にいたら自分を消す
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;

        // 起動時は組み込みフォグを有効化し、薄い状態から始める
        RenderSettings.fog = true;
        RenderSettings.fogMode = FogMode.Exponential;
        RenderSettings.fogDensity = minDensity;
    }

    // 💡 書き換え:濃い側/薄い側を反転し、フェードの実務はFadeToに任せる
    public void ToggleFog()
    {
        // 現在の状態を反転する
        isThick = !isThick;
        // 反転後の状態に応じた目標濃度へフェードさせる
        FadeTo(isThick ? maxDensity : minDensity);
    }

    // 💡 新設:指定した濃さ target へ、指定した時間 duration でフェードさせる
    public void FadeTo(float target, float duration = -1f)
    {
        // duration が負なら、従来のフィールド fadeDuration を採用する
        float dur = duration < 0f ? fadeDuration : duration;
        // すでに走っているフェードがあれば止める(多重起動防止)
        if (fadeRoutine != null) StopCoroutine(fadeRoutine);
        // フェードのコルーチンを起動する
        fadeRoutine = StartCoroutine(FadeDensity(target, dur));
    }

    // 💡 新設:最小濃度(常時の薄霧)まで、指定時間で戻す便利メソッド
    public void FadeToMin(float duration = -1f)
    {
        // 目標を minDensity にして FadeTo へ委ねる
        FadeTo(minDensity, duration);
    }

    // 💡 新設:視界を覆うカーテンの濃さへ、素早くフェードさせる(テレポート演出用)
    public void FadeToVeil()
    {
        // インスペクターで調整できる濃さと速さで覆う
        FadeTo(veilDensity, veilFadeIn);
    }

    // 💡 書き換え:引数を「目標濃度」と「所要時間」の2つにした
    private IEnumerator FadeDensity(float target, float duration)
    {
        // 開始時点の濃度を記録する
        float start = RenderSettings.fogDensity;
        // 経過時間を測る変数
        float time = 0f;

        // duration をかけて start から target へ補間する
        while (time < duration)
        {
            // 経過時間を積算する
            time += Time.deltaTime;
            // 0 → 1 の進捗
            float t = time / duration;
            // start から target へ、割合 t でなめらかに補間する
            RenderSettings.fogDensity = Mathf.Lerp(start, target, t);
            // 次のフレームまで中断する
            yield return null;
        }

        // 誤差をなくすため最後にきっちり目標値へ合わせる
        RenderSettings.fogDensity = target;
        // フェード終了を記録する
        fadeRoutine = null;
    }

    // 💡 新設:動作確認用。インスペクターのMax Densityの見え方を試せる
    [ContextMenu("霧テスト(濃く)")]
    private void TestFogThick()
    {
        // Max Densityへ、既定のフェード間隔でフェードさせる
        FadeTo(maxDensity);
    }

    // 💡 新設:動作確認用。テレポート演出と同じカーテンを試せる
    [ContextMenu("霧テスト(カーテン)")]
    private void TestFogVeil()
    {
        // 第5章の戻し演出とまったく同じ濃さ・速さで覆う
        FadeToVeil();
    }

    // 💡 新設:動作確認用。常時の薄霧に戻す
    [ContextMenu("霧テスト(元に戻す)")]
    private void TestFogMin()
    {
        // Min Densityへ、既定のフェード間隔で戻す
        FadeToMin();
    }
}

※ Assets/Scripts内の FogController.cs を修正

Fキーを失った代わりに、[ContextMenu] を3つ用意しました。インスペクターで 「Fog Controller (スクリプト)」のヘッダー部分(または右端の⋮)を右クリックすると、「霧テスト(濃く)」「霧テスト(カーテン)」「霧テスト(元に戻す)」が現れ、Play中でも実行できます。開発中の確認は、これで十分まかなえます。

[SerializeField] private float minDensity = 0.005f;
[SerializeField] private float maxDensity = 0.06f;
[SerializeField] private float veilDensity = 0.35f;
[SerializeField] private float fadeDuration = 2.0f;
[SerializeField] private float veilFadeIn = 0.7f;

コード内の上記の箇所で初期値は設定します(必要に応じ調整してください)。
コード内の値を修正した場合、先ほどの「⋮」内の『リセット』で反映させる必要があります。

🧩 なぜToggleFogから多重起動防止が消えたのか
これまでToggleFogの中に書いていた「走っているコルーチンを止めてから、新しいコルーチンを開始する」という処理は、FadeToの中へ移しました。こうしておくと、どの経路からフェードを呼んでも必ず多重起動防止が効きます。ToggleFogから呼ばれようと、第5章で作るテレポート演出から呼ばれようと、入口はFadeTo一本です。「実務を1か所に集める」という整理は、呼び出し元が増えるほど効いてきます。


■ 動作確認

  • コンパイルが通ること(コンソールにエラーが出ていないこと)。

  • コンソールに「The referenced script (Unknown) on this Behaviour is missing!」の警告が出ていないこと(出ていたら、Missing Scriptの掃除が済んでいません)。

  • Fキーを押しても、もう何も起きないこと(Fogアクションが消えたため)。

  • FogControllerのインスペクターのヘッダーを右クリックし、「霧テスト(濃く)」で霧が濃くなること。「霧テスト(カーテン)」で視界が白く覆われること(「白」かわ色の調整にもよる)。「霧テスト(元に戻す)」で常時の薄霧に戻ること。

📝 「元に戻す」は霧を消すのではなく、Min Density(0.005)の常時の薄霧へ戻すという意味です。RenderSettings.fog はAwakeでオンにしたきり、この連載では一度もオフにしません。霧のスイッチは常に入っていて、濃さだけを上下させている、という構造です。


第4章 区画Prefabを連結して回廊を組む

作業の前に(バックアップと、いまシーンにあるもの)

この章はシーンを大きく作り変えます。先に安全策と、片付けの方針を決めておきましょう。

■ バックアップを取る

こんな感じで複製

一番手軽なのは、シーンファイルの複製です。Projectウィンドウでシーンを選び、Cmd+D(WindowsはCtrl+D)でコピーを作り、ファイル名には_backup などを付け加えます。作業に失敗しても、そのシーンをダブルクリックすれば元の状態に戻れます。

📝 UVCS(Unity Version Control)やGitを使っている方は、この章に入る前にチェックイン/コミットしておけば十分です(第2弾で扱った「ブランチを切って作業する」やり方が、そのまま使えます)。


■ いまシーンにあるものを、どうするか

第1〜7弾を続けて作ってきた方のシーンには、いろいろなオブジェクトが並んでいるはずです。ここまでの進み方は人それぞれなので、「あるかどうか」で判断してください。基本方針は「消さずに使い回す」です。

区画Prefabがまだシーンに1つも無い(Prefab化だけした)方は、そのまま第4章の手順どおりドラッグして並べていけばOKです。逆に、すでにシーンに置いてある方は、そのオブジェクトのTransformに座標表の数値を入れるだけで済みます。どちらの進み方でも、結果は同じになります。

⚠️ 「Reset Shape」ボタンは押さないでください。
区画を選択すると、Pro Builder Shapeの欄に「手動で変更されているので、進めるにはShapeをリセットしてください」という警告が出ることがあります。これは第3弾で法線を反転し、面を削除した時点で「自動生成される形」の管理から外れた、という意味の正常な表示です。ここでResetを押すと、中空化も開口も消えて、ただのキューブに戻ってしまいます。警告は無視して構いません。


■ Prefab化したオブジェクト
Prefab化した時点で、本体はAssets/Prefabsフォルダなどに保存されています。シーンに置いてあるのは、その本体を参照している「インスタンス(分身)」にすぎないので、削除しても再配置できるので問題ありません。もちろん、残して使い回しても良いです。

📝 見分け方は簡単で、ヒエラルキーの名前が青く表示されていればPrefabのインスタンスです。

この章では15個の区画を並べていきます。すでにシーンにある区画(Corridor_Straightなど)は、Transformに座標表の数値を入れればそのまま使えますが、「どれが配置済みか分からなくなる」のを避けたい方は、いったん削除してまっさらな状態から並べ直すのがおすすめです。ただしTreasureHall(広間)だけは残してください。中に石碑・手記・名簿が置いてあるので、消すと配置し直しになります。

📝 もしインスタンス側で値を変更していた場合(インスペクターの項目名が太字になっているもの=オーバーライド)、削除するとその変更は失われます。区画については位置以外いじっていないはずなので、気にしなくて大丈夫です。


全体の流れ(進行図)

いよいよ回廊本体を組みます。全体のトポロジーは「入口広間 → 直線 → 分岐①(左右に分かれる)→ … → 分岐③ → 直線 → 宝物庫」という、正解ルートが1本通った構造です。各分岐には、間違った側へ進むと戻される「不正解の道(スタブ)」を短く生やしておきます。

■迷いの回廊 全体マップ(上から見た図)

全15区画。座標は記事の座標表と対応しています

区画配置の座標表(位置は中心基準)

配置順=入口から奥へ。Yはお使いの環境の床の高さに読み替えてください

  • 座標はすべて区画の中心基準。通路の長さ10とT字・L字の一辺4の格子に乗っています

  • 回転Yの値は環境により異なるため、開口の向きをシーンビューで確認しながら90度単位で調整してください

座標表のY(通路系1.5・広間2.5)は、第3弾の計算どおりの理論値です。第2章と同じく、お使いの環境の床の高さに読み替えてください(筆者の環境では通路系を1.55にしています)。X・Zの値はそのままで大丈夫です。

■ 正解の道は、ずっとこのままです。

🧩 分岐①=右(+X)→分岐②=左(-X)→分岐③=右(+X) です。区画数は合計15個(広間2・T字3・L字3・直線4・スタブ用直線3)

正解ルート

プレイのたびにランダムで変わったりはしません。「戻された→観察した→今度は正しく選べた」という上達の実感は、答えが動かないからこそ生まれます。そのかわり、毎回変わる要素は別の場所に用意してあります。分岐ごとの「見分けポイント」です(第7章で作ります)。


区画配置をする

では、区画配置をしていきましょう。

  • Prefabをヒエラルキーへドラッグします(シーンビューに直接置いても構いません)。

  • なお、すでにヒエラルキーにあるオブジェクトは、複製していくと角度も保たれ、手早く配置できます。

💡最初は『全体マップ』を参考に手作業で配置。その後「区画配置の座標表」で調整すると効率が良いかと思います。

  • ある程度配置したら、Transformに、座標表の数値を入力します。

  • 開口の向きを、シーンビューで確認しながら回転Yを90度単位で合わせます(回転値は環境により前後します。「開口がどちらを向くか」を目で見て決めるのが確実です)。


■ 小技(シーンの方角)

シーンの方角を確認したいときは、シーンビュー右上のシーンギズモの「y」(緑のコーン)をクリックして、Topビュー(真上から見た状態)にするのが簡単です。上が+Z、右が+Xになり、この記事の全体マップとまったく同じ向きになります。本記事では、この+Zを「北」、+Xを「東」と呼んでいます。


広場の通路側の壁の空きについて

■ 左右の隙間
広間の開口は壁1枚まるごと(幅12m)なのに対し、通路の幅は4mです。通路を開口の中央に刺すと、左右に4mずつ壁のない隙間ができ、そこから外へ出られてしまいます。第3弾で「法線反転方式では壁1枚が最小単位なので、部分的には開けられない」と説明した、その帰結です。

気にせず進めても迷路としては成り立ちますが、塞ぐのは簡単なので、ここでやっておきましょう。

  1. 「ゲームオブジェクト」→「ProBuilder」→「キューブ」で新規作成し、名前を EntranceBlock_L にします。

  2. Pro Builder Shapeのサイズを X:4 / Y:5 / Z:0.2 にします(広間の高さ5に合わせた薄い板)。

  3. 位置を (-4, 2.5, 6) にします(入口広間の+Z側の壁面、開口の左側の余りをちょうど埋めます)。

  4. 複製(Cmd+D)して EntranceBlock_R にし、位置Xを +4 に変えます((4, 2.5, 6))。

  5. 宝物庫((4, 2.5, 64)・開口は-Z側)にも同じ要領で2枚。サイズは同じで、位置は (0, 2.5, 58) と (8, 2.5, 58)です。

※ 数値は目安です、状況に応じ適宜調整をしてください。


■ 上側の隙間
あと、上側も同じく空きます。広間の高さは5m、通路の高さは3m。その差の2mぶん、通路の真上に隙間が残るためです。

  1. キューブを新規作成し、名前を EntranceBlock_T にします。

  2. Pro Builder Shapeのサイズを X:4 / Y:2 / Z:0.2 にします(幅は通路と同じ4、高さは広間5-通路3の差=2)。

  3. 位置を (0, 4, 6) にします(通路の天井3mから広間の天井5mまでの、ちょうど中間の高さです)。

  4. 宝物庫側も同様に、位置 (4, 4, 58) へ1枚。

📝 これで、広間ごとに板が3枚(左・右・上)になります。「開口の壁のうち、通路が刺さる4×3の穴だけを残して、残りを板で埋める」と考えると分かりやすいです。この考え方は、広間と通路の寸法が違うときにいつでも使えます。


反転の掟の石碑を設置する

深層では判定が反転します。その手がかりとして、第5・6弾で作った石碑を複製し、掟を刻んで通路Cに置きます。

中身(StoneMonument.cs・3D TMP・LoreUI参照・日本語フォント)は同じものを使い回します

元の石碑も、そのまま残します。
これで遺跡に石碑が2本立つことになります。入口広間にある元のMonumentは「この遺構は、古の民が知恵を競った学び舎である。」という世界観を語る石碑。今回作るMonument_Ruleは、深層に入った直後の通路に立ち、「深層では判定が反転する」ことを予告する攻略のヒントです。役割が違うので、両方あって構いません。

複製後に変えるのは、この2つだけ

なぜ複製で作るのかというと、中身がまるごと使えるからです。StoneMonument.cs、3D TMPの設定、LoreUIへの参照、日本語フォントの割り当て …… 第5・6弾で整えたものが全部入っています。ゼロから作れば同じ手順をもう一度たどることになりますが、複製なら「置く場所」と「参照する刻文」の2つを変えるだけで済みます。

  1. 既存のMonument(StoneMonument.cs付き)とMonumentInscription(石碑表面の3D TMP)を一緒に複製(親を複製すれば子も一緒についてきます)し、名前を Monument_Rule にします。

  2. 位置を、たとえば (1.5, 1, 37) に置きます(通路Cの東壁際)。刻文面が通路側を向くよう、回転Yを調整します(第6弾で解説した「石碑の表裏の罠」に注意)。

  3. メニュー「ウィンドウ」→「アセット管理(Asset Management)」→「Localization Tables」を選択。

Localization Tablesウィンドウで String Table「GameText」を開き、新しいエントリ rule_inscription を追加します。

・英語(en):「In the deep, the flawed is true. Doubt the flawless treasure.」
・日本語(ja):「深き底にては、綻びこそ真なり。完き宝は、かえって疑え。」

『Monument_Rule』のStoneMonumentコンポーネントで、Inscriptionの割当を GameText/rule_inscription に変更します。Prompt Entryは既存の prompt_examine(調べる)を流用します。

『MonumentInscription(3D TMP)』のLocalize String EventのString Referenceも rule_inscription へ変更します。

⚠️ 石碑に刻む文言は、ひらがな+基本漢字の範囲にとどめます。第6弾で作った日本語フォントアセットはStatic(文字を焼き込む方式)なので、焼き込んでいない文字を使うと豆腐(□)になります。第7弾の銘板と同じ注意です。使う文字がフォントアセットの文字リストに含まれているか、確認してください。

■ 動作確認

  • 入口から宝物庫まで、正解ルートを一周歩けること。

  • 区画同士の接続部にすき間やちらつきがないこと(断面はすべて4×3で揃っているので、座標が正しければぴたりと合います)。

  • 広間まわりは、塞ぎ板3枚で埋まっていること。

📝 スタブの奥は開いたままですが、いまは問題ありません。第5章でTeleportTriggerを置くと、奥に到達する前に戻されるようになります。気になる方は、幅4×高さ3×薄さ0.2の板を1枚立てて塞いでおいても構いません。

  • 石碑を調べると、掟の全文がLoreUIで読めること(言語を切り替えると英語にもなること)。


第5章 ソフト失敗ループ(テレポート)

PlayerLocomotionを改修する

ここからが本記事の核心です。不正解の道の奥にトリガーを置き、そこに触れたら「霧が満ちる→ワープ→霧が引く」という一連の流れを起こします。プレイヤーの体験としては、暗い罰ではなく、「濃い霧に巻かれているうちに、いつのまにか分岐の少し手前に戻っていた」という、やさしい迷いの演出になります。急に画面が切り替わるのではなく、霧のカーテンが上がって下りる、その裏でこっそり位置を入れ替える。これが「シームレステレポート」です。

テレポート用メソッドをPlayerLocomotionに追加します。変更は1つだけです。
『TeleportTo(Vector3 position, Quaternion rotation)』の新設。
CharacterControllerを安全にワープさせるメソッド。既存の処理には一切手を加えません。クラスの末尾(ApplyGravityOnly() の上あたり)に追記する形になります。


なお、このメソッドの中には、次章で有効化する⏳予約席が1行あります。コメントアウトされたままにしておいてください。いま外すと、まだ存在しない NotifyWarp を呼ぶことになり、コンパイルエラー(CS1061)になります。第5弾で使った「先に穴だけ開けておく」やり方の再演です。

// ==========================================================
// 📄 スクリプト:PlayerLocomotion.cs(改修後・全体)
// ==========================================================
using UnityEngine;
using UnityEngine.InputSystem; // InputAction型を使うため、引き続き必要です

// [RequireComponent] …このスクリプトにはCharacterControllerが必須(無ければ自動で付く)
[RequireComponent(typeof(CharacterController))]
public class PlayerLocomotion : MonoBehaviour
{
    [Header("移動スピードの設定")]
    [SerializeField] private float walkSpeed = 2f;   // 歩く速さ。Blend Treeの「2」と同じにします。
    [SerializeField] private float runSpeed = 6f;    // 走る速さ。Blend Treeの「6」と同じにします。
    [SerializeField] private float rotationSmooth = 0.12f; // 振り向く時の滑らかさ。小さいほど機敏。

    [Header("重力(落下)の設定")]
    [SerializeField] private float gravity = -15f;        // 重力の強さ。マイナスは「下向き」。
    [SerializeField] private float groundedGravity = -2f; // 接地時に地面へ押し付ける力。

    [Header("ジャンプの設定")]
    [SerializeField] private float jumpHeight = 1.5f; // ジャンプの最高到達点(メートル)。

    [Header("カメラの参照")]
    [SerializeField] private Transform cameraTransform; // 「どっちが前か」の基準になるカメラ。

    // FPS判定のための参照(第5弾で有効化済み)
    [SerializeField] private CameraModeSwitcher cameraModeSwitcher;

    // trueの間、移動・ジャンプの入力を受け付けなくする「操作ロック」
    public bool InputLocked { get; set; } = false;

    // --- 内部で使う部品 ---
    private CharacterController _controller; // 移動用コンポーネントのリモコン
    private Animator _animator;              // アニメーションの司令塔のリモコン

    // 生成クラス(GameControls)と、そこから取り出すアクションの参照
    private GameControls _controls;
    private InputAction _moveAction;   // 前後左右の入力
    private InputAction _sprintAction; // ダッシュ(左Shift)
    private InputAction _jumpAction;   // ジャンプ(スペース)

    private float _verticalVelocity;   // 縦方向(上下)の現在速度
    private float _turnSmoothVelocity; // SmoothDampAngleが内部で使うメモ

    // Animatorのパラメーター名は、処理が軽い「数字(Hash)」に変換して持っておく
    private readonly int _speedHash    = Animator.StringToHash("Speed");
    private readonly int _groundedHash = Animator.StringToHash("Grounded");
    private readonly int _jumpHash     = Animator.StringToHash("Jump");

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
        _animator   = GetComponent<Animator>();

        // 生成クラスから「型安全」にアクションを取得する
        _controls     = new GameControls();
        _moveAction   = _controls.Player.Move;
        _sprintAction = _controls.Player.Sprint;
        _jumpAction   = _controls.Player.Jump;

        // カメラ未設定なら自動でMain Cameraを探す
        if (cameraTransform == null && Camera.main != null)
        {
            cameraTransform = Camera.main.transform;
        }
    }

    // 生成クラスのアクションは Enable() しないと一切反応しません
    private void OnEnable()  { _controls.Player.Enable(); }
    private void OnDisable() { _controls.Player.Disable(); }

    private void Update()
    {
        // --------------------------------------------------------
        // 0. 操作ロックの判定
        // --------------------------------------------------------
        // ロック中は入力を読まずに帰ります。ただし重力だけは効かせ続けます。
        // UI表示中(UiGate.IsOpen)も同じ扱いです。
        if (InputLocked || UiGate.IsOpen)
        {
            _animator.SetFloat(_speedHash, 0f); // 走行アニメを止める
            ApplyGravityOnly();                 // 重力のみ適用
            _animator.SetBool(_groundedHash, _controller.isGrounded);
            return;
        }

        // --------------------------------------------------------
        // 1. プレイヤーの操作(入力)を読み取る
        // --------------------------------------------------------
        Vector2 input = _moveAction.ReadValue<Vector2>();
        bool isRunning = _sprintAction.IsPressed();

        // --------------------------------------------------------
        // 2. どちらに進むか(カメラを基準にした方向)を計算する
        // --------------------------------------------------------
        Vector3 camForward = cameraTransform != null ? cameraTransform.forward : Vector3.forward;
        Vector3 camRight   = cameraTransform != null ? cameraTransform.right   : Vector3.right;

        camForward.y = 0f;
        camRight.y = 0f;
        camForward.Normalize();
        camRight.Normalize();

        Vector3 moveDir = camForward * input.y + camRight * input.x;
        float inputMagnitude = Mathf.Clamp01(moveDir.magnitude);

        if (moveDir.sqrMagnitude > 0.0001f)
        {
            moveDir.Normalize();
        }

        // --------------------------------------------------------
        // 3. 実際の「移動スピード」を決定する
        // --------------------------------------------------------
        float targetSpeed = (isRunning ? runSpeed : walkSpeed) * inputMagnitude;

        // --------------------------------------------------------
        // 4. キャラクターを進行方向へ「滑らかに」振り向かせる
        // --------------------------------------------------------
        // 一人称(FPS)のときは、体の向きをマウスルックが担当するのでスキップします
        bool isFirstPerson = cameraModeSwitcher != null && cameraModeSwitcher.IsFirstPerson;

        if (inputMagnitude > 0.01f && !isFirstPerson)
        {
            float targetAngle = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg;
            float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle,
                                                ref _turnSmoothVelocity, rotationSmooth);
            transform.rotation = Quaternion.Euler(0f, angle, 0f);
        }

        // --------------------------------------------------------
        // 4.5. ジャンプの処理
        // --------------------------------------------------------
        if (_controller.isGrounded && _jumpAction.WasPressedThisFrame())
        {
            // v = √(2 × 高さ × 重力) で「欲しい高さ」に届く初速を逆算
            _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
            _animator.SetTrigger(_jumpHash);
        }

        // --------------------------------------------------------
        // 5. 重力(落下)の計算
        // --------------------------------------------------------
        if (_controller.isGrounded && _verticalVelocity < 0f)
        {
            _verticalVelocity = groundedGravity;
        }
        else
        {
            _verticalVelocity += gravity * Time.deltaTime;
        }

        // --------------------------------------------------------
        // 6. 実際にキャラクターを動かす
        // --------------------------------------------------------
        Vector3 horizontal = moveDir * targetSpeed;
        Vector3 velocity = horizontal + Vector3.up * _verticalVelocity;
        _controller.Move(velocity * Time.deltaTime);

        // --------------------------------------------------------
        // 7. アニメーション(司令塔)に現在の状態を報告する
        // --------------------------------------------------------
        _animator.SetFloat(_speedHash, targetSpeed);
        _animator.SetBool(_groundedHash, _controller.isGrounded);
    }

    // 💡 新設:指定した位置と回転へ、CharacterControllerを安全にワープさせる
    public void TeleportTo(Vector3 position, Quaternion rotation)
    {
        // ワープ前の位置を記録しておく(カメラ通知の差分計算に使う)
        Vector3 oldPosition = transform.position;
        // CharacterControllerを一時的に無効化する(位置上書きを防ぐ定石)
        _controller.enabled = false;
        // 位置と回転を一括で設定する
        transform.SetPositionAndRotation(position, rotation);
        // CharacterControllerを再び有効化する
        _controller.enabled = true;
        // 物理エンジンへ変更を明示的に反映する(autoSyncTransformsがfalseのため)
        Physics.SyncTransforms();
        // 落下速度をリセットして、着地時のガタつきを防ぐ
        _verticalVelocity = 0f;
        // ⏳予約席:カメラへのワープ通知は次章で有効化します(第5弾の⏳方式の再演)
        // cameraModeSwitcher?.NotifyWarp(position - oldPosition);
    }

    // ロック中でも重力だけは処理するためのメソッド
    private void ApplyGravityOnly()
    {
        if (_controller.isGrounded && _verticalVelocity < 0f)
        {
            _verticalVelocity = groundedGravity;
        }
        else
        {
            _verticalVelocity += gravity * Time.deltaTime;
        }
        _controller.Move(new Vector3(0f, _verticalVelocity, 0f) * Time.deltaTime);
    }

    // アニメーションからの合図を受け取る箱(第1弾のまま)
    private void OnFootstep(AnimationEvent animationEvent) { }
    private void OnLand(AnimationEvent animationEvent) { }
}

上記のコード全文は、第5弾の改修(生成クラス化・InputLocked・cameraModeSwitcher参照)と第6弾の改修(|| UiGate.IsOpen)がすべて適用済みの状態です。途中の弾を飛ばしている方は、該当箇所をご自身の環境に合わせて読み替えてください。

📝 oldPosition は、いまはまだ「宣言しただけで使っていない」状態なので、コンパイラが警告(CS0219)を出すかもしれません。次章でコメントを外せば消えるので、そのままで大丈夫です。


MazeDirector(新規・シングルトン・戻し担当部分)

迷路全体を取り仕切る司令塔クラスを新設します。この章では「プレイヤーを戻す」部分だけ実装します。

// ==========================================================
// 📄 スクリプト:MazeDirector.cs(新規・第5章時点)
// ==========================================================
using System.Collections;
using UnityEngine;

// 迷路全体を統括するシングルトン
public class MazeDirector : MonoBehaviour
{
    // どこからでも1つの実体へアクセスできるようにする
    public static MazeDirector Instance { get; private set; }

    // プレイヤーの移動スクリプトへの参照
    [SerializeField] private PlayerLocomotion player;

    // 戻し処理中かどうかのフラグ(多重発火を防ぐ)
    private bool isReturning;

    // 外から読み取れるように公開する
    public bool IsReturning => isReturning;

    // シーン開始時にシングルトンを設定する
    private void Awake()
    {
        // すでに実体があれば自分を破棄する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        // 自分を唯一の実体として登録する
        Instance = this;
    }

    // 指定アンカーへプレイヤーをやさしく戻す(外部から呼ぶ入口)
    public void ReturnPlayer(Transform anchor)
    {
        // すでに戻し処理中なら無視する
        if (isReturning) return;
        // 戻しコルーチンを起動する
        StartCoroutine(ReturnRoutine(anchor));
    }

    // 霧で覆う→ワープ→霧を引く、の一連の流れ
    private IEnumerator ReturnRoutine(Transform anchor)
    {
        // 戻し処理中フラグを立てる
        isReturning = true;
        // プレイヤーの入力を止める(重力のみ働く状態)
        player.InputLocked = true;
        // 視界を覆うカーテンで包む(濃さと速さはFogControllerのインスペクターで調整可能)
        FogController.Instance.FadeToVeil();
        // 霧が満ちるのを少し待つ
        yield return new WaitForSeconds(0.8f);
        // 霧が濃いうちに、プレイヤーをアンカーへワープさせる
        player.TeleportTo(anchor.position, anchor.rotation);
        // 霧を常時の薄霧へゆっくり戻す(既定のフェード間隔=2秒を使う)
        FogController.Instance.FadeToMin();
        // 霧が引きはじめてから少し待つ
        yield return new WaitForSeconds(0.5f);
        // 入力を再開する
        player.InputLocked = false;
        // 戻し処理中フラグを下ろす
        isReturning = false;
    }
}

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

■ なぜ専用の濃さを使うのか

0.06では、ワープの瞬間を隠しきれません。だから専用の値を別に用意しました。濃さも速さもインスペクターで調整できます。第3章の「霧テスト(カーテン)」を実行すれば、この演出とまったく同じ見え方をその場で確認できます


■ ここでInputLockedが初めて実戦投入されます

第5弾で「いつか使う布石」として仕込んでおいた入力ロックが、ここで効きます。もし入力を止めなければ、走行速度6m/sでは、0.8秒の霧のあいだにプレイヤーが自分でスタブを走り抜けてしまう計算になります(6×0.8=4.8m)。だから霧のあいだは入力を止めるのです。


■ MazeDirectorをシーンに置く
スクリプトを書いただけでは動きません。司令塔の実体をシーンに用意します。

  1. ヒエラルキーで右クリック →「空のオブジェクトを作成」。名前を MazeDirector にします(位置はどこでも構いません。FogControllerやGameManagerの隣に置いておくと見つけやすいです)。

  2. インスペクターの「コンポーネントを追加」から MazeDirector を検索してアタッチします。

  3. アタッチすると Player という欄が現れるので、ヒエラルキーのアバター(model_test)をドラッグ&ドロップで割り当てます。

📝 このオブジェクトには、第8章で ProgressTracker(無進捗タイマー)も一緒にアタッチします。いまはMazeDirectorだけで大丈夫です。


TeleportTrigger(新規)

不正解の道の奥に置く、戻しトリガーです。

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

// 触れるとプレイヤーを戻すトリガー
public class TeleportTrigger : MonoBehaviour
{
    // 戻し先のアンカー(分岐手前・分岐方向を向く)
    [SerializeField] private Transform returnAnchor;

    // 連続発火を防ぐクールダウン秒数
    [SerializeField] private float cooldown = 3f;

    // 次に発動できるようになる時刻
    private float nextReadyTime;

    // 何かがトリガーに入ったとき
    private void OnTriggerEnter(Collider other)
    {
        // クールダウン中なら無視する
        if (Time.time < nextReadyTime) return;
        // CharacterControllerを持つ相手(=プレイヤー)だけ反応する
        if (other.GetComponent<CharacterController>() == null) return;
        // 次に発動できる時刻を更新する(再アーム式)
        nextReadyTime = Time.time + cooldown;
        // 司令塔にプレイヤーの戻しを依頼する
        MazeDirector.Instance.ReturnPlayer(returnAnchor);
    }
}

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

other.GetComponent<CharacterController>() != null の1行が、実は「導きの精霊などがトリガーを誤って踏んでも発動しない」フィルタになります。CharacterControllerを持っているのはプレイヤーだけだからです。

インスペクターで設定するのは「returnAnchor」「cooldown」の2つだけとなります。割り当てのシーンの構築をしましょう。

■ シーン構築手順
※ 数値は目安です。必要に応じ適宜調整を。

ReturnAnchor①〜③(空オブジェクト)を作ります。
位置は (0,1,13) / (4,1,27) / (0,1,35)、回転Y=0で+Z向きにします。📝 Yはプレイヤーの位置Y(=1)と同じです。
※ めり込む・浮く場合はYを微調整してください。

📝 ReturnAnchor③のzはテレポート後に石碑が見えるような値が親切かと思います。

TeleportTrigger①〜③(空オブジェクト)を作ります。
位置は (-8,1.2,18) / (12,1.2,32) / (-8,1.2,46)。
Box Colliderを「追加」し、Is Triggerをオンにします。サイズは通路断面に合わせ(例:4×2.5×4)にします。レイヤーはDefaultのままにします。

📝 Interactableにしない理由は、InteractorのLayerMaskがDefaultを素通しするので、このトリガーに「調べる」プロンプトが出なくなるからです。

各TriggerにTeleportTrigger.csを「コンポーネントを追加」でアタッチし、returnAnchorに対応するReturnAnchorを割り当てます。

なお、CharacterControllerはRigidbodyを持たなくてもTriggerを発火させます(第5弾・落とし穴①で触れた例外的挙動です)。だからこのトリガーはRigidbodyなしで機能します。


■ 動作確認

  • 不正解の道へ入ると、霧が満ちて戻され、霧が引くこと。

  • 戻り先で、分岐の方向を向いていること。

  • 落下でガタつかないこと(_verticalVelocityリセットの効果)。

  • TPSでテレポートすると、その瞬間カメラが世界を横断してビュンと飛ぶこと。……この問題は、あえて次章まで残します。


第6章 テレポート時のCinemachine対応

なぜ、カメラが世界を横断するのか

前章で残した「カメラが世界を横断して飛ぶ」問題の正体を突き止めます。

TPSのカメラは、CinemachineOrbitalFollowでプレイヤーを追従しています。ダンピング(追従の滑らかさ)が効いているため、プレイヤーが一瞬で遠くへ飛ぶと、カメラはそれを「超高速の移動」と解釈し、旧位置から新位置まで実際に滑って追いかけてしまうのです。だから世界を横切って飛んで見えます。

渡すのは「移動先 − 移動元」の差分。カメラに「同じだけ飛べ」と伝えるイメージです

そこで「cameraModeSwitcher.NotifyWarp(position - oldPosition);」を使います。

これは「プレイヤーがワープしたよ」とカメラに知らせる1行です。知らせを受けたCinemachineは、内部に覚えている「前フレームのターゲット位置」を、渡された差分ぶんだけずらします。すると次のフレームには「ターゲットは動いていない」ように見えるので、カメラは追いかける必要がなくなり、そのままワープ先で正しい位置に収まります。

📝 position - oldPosition は「移動先 − 移動元」、つまりどれだけ飛んだかの差分です。カメラに「同じだけ一緒に飛んで」と伝えているイメージですね。引き算の順序が逆だと反対方向へずれるので、そこだけ注意してください。

この1行がなくてもゲーム自体は動きます(数秒待てばカメラも追いついてきます)。ただ、その数秒で視界が壁を突き抜けて流れていくので、せっかく霧で隠したワープの瞬間が台無しになってしまいます。演出を成立させるための1行、という位置づけです。


CameraModeSwitcherの改修(差分)

TPSカメラへワープを通知するメソッドを追加します。

// ==========================================================
// 📄 スクリプト:CameraModeSwitcher.cs(差分・追記メソッド)
// ==========================================================
    // プレイヤーがワープしたことをTPSカメラへ通知する
    public void NotifyWarp(Vector3 positionDelta)
    {
        // FPS中はカメラ通知が不要なので、TPSのときだけ通知する
        if (!IsFirstPerson)
        {
            // 追従対象(playerRoot)がpositionDeltaぶんワープしたとCinemachineへ伝える
            tpsCamera.OnTargetObjectWarped(playerRoot, positionDelta);
        }
    }

FPS中に通知が不要な理由:FPS時はMain CameraがLateUpdateで毎フレーム頭(HeadAnchor)に吸い付いています。つまりカメラは常にプレイヤーと一緒に動くので、ワープしても勝手に追従します。だからIsFirstPersonで分岐して、TPSのときだけCinemachineへワープをアナウンスすればOKです。


PlayerLocomotionの「予約席」を有効化

前章でコメントアウトしておいた1行を、いま有効にします。

// ==========================================================
// 📄 スクリプト:PlayerLocomotion.cs(TeleportTo内・⏳予約席の有効化)
// ==========================================================
        // ワープした差分ぶん、カメラにも同じ移動を伝えて世界横断を防ぐ
        cameraModeSwitcher?.NotifyWarp(position - oldPosition);

TeleportToの末尾にあったコメント記号を外すだけです。第5弾で確立した「まず穴だけ開けておき、あとで栓をする」⏳予約席方式の回収です。これで、テレポート前に記録したoldPositionとの差分が、そのままカメラへ渡ります。

⚠️ Cinemachine関連の既知の留意点として、Unity Issue TrackerのIN-99893では、CinemachineOrbitalFollowのPositionDampingが0より大きいとき、OnTargetObjectWarped(およびForceCameraPosition)を使っても、環境によりカメラとターゲットの相対位置が完全には保たれない場合がある、と報告されています。本連載では、ワープの瞬間を濃い霧で覆っているため、実用上は問題になりません。それでも気になる場合は、ワープの瞬間だけ一時的にDampingを0にする手もあります。


■ 動作確認

  • TPSでテレポートしても、カメラが一瞬で背後に付いてくるだけで、世界を横断しないこと。

  • FPSでテレポートしても違和感がないこと。

  • V切替(TPS⇄FPS)を挟んでも正常に動くこと。


第7章 MazeDirectorと記憶ギミック

記憶ギミック

この章で作るのは、ひとことで言うと「道は覚えられるけれど、手抜きはできない」仕組みです。

この迷路の基本は「正しい道を選ぶこと」です。間違えると霧で戻されますが、何度か繰り返すうちに「なるほど、分岐①は右が正解なんだな」と学習できます。ここで、もし分岐①がずっと同じ問題(たとえば、左の箱の目が光っている)だったらどうなるでしょう。プレイヤーは箱をよく見ずに「はいはい、右ね」と通り過ぎるようになり、せっかくの間違い探しがただの作業になってしまいます。

分岐ごとの記憶ギミック(Dictionary)がやっていること

そこで「正解の道は変えないけれど、間違い探しの『お題』だけコッソリ変える」というイタズラを仕掛けます。分岐①で間違えて、戻ってきたときを想像してください。

  • ❌ 変わらないこと「どちらの道が正解か」
    分岐①は何度やり直しても「右が正解(=左の箱が偽物)」のままです。苦労して覚えた正解ルートは裏切りません。

  • ⭕️ 変わること「左の箱の『どこがおかしいか』」 さっきは目が光っていたのに、もう一度来てみると、今度は銘板の文字が間違っている……ということが、4割ほどの確率で起きます。

こうなると、「道は右で合っているはずだけど、念のため左の箱のどこがおかしいか確かめておこう」と、毎回しっかり観察したくなります。これが狙いの「適度な緊張感」です。

「たまに変えるなら、毎回ランダムでいいのでは?」と思うかもしれません。ところが、記憶なしで毎回抽選し直すと、変なことが起きます。この抽選は、プレイヤーが分岐のエリアに入るたびに走ります(この章の後半で作るJunctionTriggerの仕事です)。つまり、箱の前をうろうろして分岐にちょっと入り直すだけで、そのたびに箱の姿が変わってしまう……さっきまで目が光っていたはずなのに、出入りしただけで飾りが増えている。不自然で、壊れた世界になってしまうのです。

これを防ぐために、司令塔(MazeDirector)に「さっきプレイヤーに何を見せたか」を記憶させます。これがDictionaryの役割です。

  • 6割の確率で
    さっき見せたのと同じ姿を維持して、自然な世界を保つ。

  • 4割の確率で
    プレイヤーが霧で戻されたりして離れた隙に、別のお題へコッソリすり替える。

📝 この4割のくじは、分岐に入り直すたびに独立して1回引かれます
 
つまり、間違えて戻された回数が多い人ほど「化け直り」に出会いやすくなります(入り直し2回でどこかで変わっている確率は64%、3回で約78%)。逆に、一発で正解し続ける人は、くじ自体をほとんど引かないので、化け直りを一度も見ないままクリアすることもあります。観察をサボって間違えた人ほど「丸暗記が通用しない」場面に多く出会う——ちょうど良い方向に働く設計です。なお、引き直しの4割を引いても、抽選の結果たまたま同じお題に戻ることがあります(履歴の除外は直近2件だけのため)。動作確認で「引き直したはずなのに同じだった」ように見えても、バグではありません。

間違えた回数が多い人ほど、化け直りに出会いやすい

まとめると、この仕組みがやっているのは、「出入りするたびに箱が変わったら不自然だから、基本は同じ姿をキープ(記憶)しておくね。でも、ただのルート暗記ゲームにならないように、たまに隙を見て『間違い探しの問題』だけすり替えておくよ」という、ゲームを面白くするための「親切なイタズラ」です。


TellLotteryをシングルトンにする

このあとMazeDirectorから TellLottery.Instance を呼ぶので、先にこちらを済ませます。順番を逆にすると、存在しないメンバーを参照することになり error CS0117 でコンパイルが止まります。

改修は2箇所です。抽選のロジックには一切手を触れません。差分が離れた位置にあるので、全体を載せます。コメントで「💡 追加:」とあるのが今回の追加分です。

  • 💡 追加 Instance プロパティ:クラスの先頭、既存のフィールドの上。

  • 💡 追加 Awake():第7弾のTellLotteryにはAwakeがないので、新規作成になります。

// ==========================================================
// 📄 スクリプト:TellLottery.cs(改修後・全体)
// ==========================================================
using System.Collections.Generic;
using UnityEngine;

// カタログ(見分けポイントSOの一覧)から、現在のフェーズに応じて
// 1件を重み付きで抽選する装置。直近に出たものは履歴Queueで除外します。
public class TellLottery : MonoBehaviour
{
    // 💡 追加:どこからでもアクセスできる唯一の実体
    public static TellLottery Instance { get; private set; }

    [Header("カタログ(見分けポイントのSOアセットを登録)")]
    [SerializeField] private List<MimicTellData> catalog = new List<MimicTellData>();

    [Header("直近何件を「出さない」ようにするか")]
    [SerializeField] private int historySize = 2;

    // 直近に選ばれたものを、古い順に覚えておく行列(FIFO)
    private readonly Queue<MimicTellData> recentHistory = new Queue<MimicTellData>();

    // 💡 追加:シーン開始時に実体を登録する
    private void Awake()
    {
        // すでに実体があれば自分を破棄する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        // 自分を唯一の実体にする
        Instance = this;
    }

    // あるデータの、現在フェーズでの重みを返す
    private int GetWeight(MimicTellData data, GameManager.DepthPhase phase)
    {
        switch (phase)
        {
            case GameManager.DepthPhase.Shallow: return data.weightShallow;
            case GameManager.DepthPhase.Middle:  return data.weightMiddle;
            case GameManager.DepthPhase.Deep:    return data.weightDeep;
            default: return 0;
        }
    }

    // フェーズを受け取り、見分けポイントを1件抽選して返す
    public MimicTellData Pick(GameManager.DepthPhase phase)
    {
        // ① 候補を絞る:重みが1以上 かつ 直近履歴に入っていないもの
        List<MimicTellData> candidates = new List<MimicTellData>();
        foreach (var data in catalog)
        {
            if (GetWeight(data, phase) > 0 && !recentHistory.Contains(data))
                candidates.Add(data);
        }

        // ② 全部が履歴で弾かれてしまったら、履歴を無視してもう一度絞る(保険その1)
        if (candidates.Count == 0)
        {
            foreach (var data in catalog)
            {
                if (GetWeight(data, phase) > 0)
                    candidates.Add(data);
            }
        }

        // ③ それでも0件=このフェーズに出せるデータが1つも無い(台帳の設定ミス)
        if (candidates.Count == 0)
        {
            Debug.LogWarning("TellLottery: このフェーズで出せる見分けポイントがありません");
            return null;
        }

        // ④ 重み付き抽選(累積和方式)で1件選ぶ
        MimicTellData picked = PickWeighted(candidates, phase);

        // ⑤ 履歴に記録し、あふれたぶんは古い順に忘れる(固定長キューの定石)
        recentHistory.Enqueue(picked);
        while (recentHistory.Count > historySize)
            recentHistory.Dequeue();

        return picked;
    }

    // 重み付き抽選の本体(累積和方式)
    private MimicTellData PickWeighted(List<MimicTellData> candidates,
                                       GameManager.DepthPhase phase)
    {
        // 重みの合計=数直線の全長を出す
        int totalWeight = 0;
        foreach (var data in candidates)
            totalWeight += GetWeight(data, phase);

        // 0〜合計の乱数を1本引く
        float r = Random.Range(0f, totalWeight);

        // 数直線を重みの幅で区切りながら、乱数が入った区間の持ち主を探す
        float cumulative = 0f;
        foreach (var data in candidates)
        {
            cumulative += GetWeight(data, phase);
            if (r < cumulative)
                return data;
        }

        // 乱数が上限ぴったりだった等、判定をすり抜けたときの保険その2
        return candidates[candidates.Count - 1];
    }

    // ⋮メニューから実行できる抽選テスト。Play中に使います
    [ContextMenu("抽選テスト(10回)")]
    private void DebugPick10()
    {
        if (GameManager.Instance == null)
        {
            Debug.LogWarning("Playモードで実行してください(GameManagerが必要です)");
            return;
        }
        for (int i = 0; i < 10; i++)
        {
            var tell = Pick(GameManager.Instance.CurrentPhase);
            Debug.Log($"抽選{i + 1}回目: {(tell != null ? tell.tellName : "なし")}");
        }
    }
}

JudgeFeedbackUIも同様に

こちらも同じくシングルトンにします(第10章で JudgeFeedbackUI.Instance を呼ぶためです)。ただしすでに Awake() があるので、その中に2行足す形になります。

// ==========================================================
// 📄 スクリプト:JudgeFeedbackUI.cs(改修後・全体)
// ==========================================================
using System.Collections;
using UnityEngine;
using TMPro;

// 正解/不正解の結果を、ふわっと表示してふわっと消すUI。
// パネルは常にアクティブのまま、CanvasGroupのalpha(透明度)だけを動かします。
public class JudgeFeedbackUI : MonoBehaviour
{
    // 💡 追加:どこからでもアクセスできる唯一の実体
    public static JudgeFeedbackUI Instance { get; private set; }

    [SerializeField] private CanvasGroup canvasGroup;   // パネルのCanvasGroup
    [SerializeField] private TextMeshProUGUI label;     // 結果の文字
    [SerializeField] private float fadeDuration = 0.3f; // フェードにかける秒数
    [SerializeField] private float holdDuration = 1.8f; // 表示を保つ秒数

    private Coroutine routine; // 走行中の演出を覚えておく(多重起動防止の定石)

    private void Awake()
    {
        // 💡 追加:シングルトンとして自分を登録する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;

        canvasGroup.alpha = 0f;             // 最初は見えない(オブジェクトは生きている)
        canvasGroup.interactable = false;   // 操作対象にしない
        canvasGroup.blocksRaycasts = false; // クリックも素通しにする(表示専用のUIなので)
    }

    public void Show(bool correct, string tellName)
    {
        // 見分けポイントの名前も一緒に出すと、外したときの学びになります
        label.text = correct
            ? $"せいかい!\n(見分けポイント:{tellName})"
            : $"はずれ……\n(見分けポイント:{tellName})";

        if (routine != null) StopCoroutine(routine); // 走っていたら止めてから
        routine = StartCoroutine(FadeRoutine());     // 新しく開始(第4弾の定石)
    }

    private IEnumerator FadeRoutine()
    {
        yield return Fade(0f, 1f);                     // フェードイン
        yield return new WaitForSeconds(holdDuration); // しばらく表示
        yield return Fade(1f, 0f);                     // フェードアウト
        routine = null;
    }

    // alphaをfromからtoへ、fadeDuration秒かけてなめらかに動かす。
    // 「timeを足す→tを求める→Mathf.Lerp→yield return null」……
    // 第4弾のFogControllerとまったく同じ骨格です
    private IEnumerator Fade(float from, float to)
    {
        float time = 0f;
        while (time < fadeDuration)
        {
            time += Time.deltaTime;
            float t = time / fadeDuration;
            canvasGroup.alpha = Mathf.Lerp(from, to, t);
            yield return null;
        }
        canvasGroup.alpha = to; // 誤差をなくすため最後にきっちり目標値へ
    }
}

昇格について整理

第7弾では「呼び手が1つ(ChestPairControllerだけ)だから、参照割当で十分。シングルトンの乱用は避ける」と書きました。今回は状況が変わりました。呼び手がChestPairControllerとMazeDirectorに複数化し、しかも宝箱をPrefab化すると、シーン内のオブジェクトへの参照(SerializeField割当)は切れてしまいます。つまり「シーンに1つだけ存在し、あちこちから呼ばれる」という、シングルトンにふさわしい条件を満たしました。だから昇格させます。第7弾で示した判断基準(乱用しない・条件を満たしたら使う)は、一貫しています。


MazeDirectorに記憶ギミックを追加する

続いて司令塔です。第5章で作ったMazeDirector.csに、記憶のための道具を足します。追加は3種類、位置は次のとおりです。

  • 💡 追加(using) using System.Collections.Generic;:ファイル冒頭。Dictionary と HashSet を使うために必要です。

  • 💡 追加(フィールド) relotteryChance・records・solved:既存のフィールド(player・isReturning)の下。

  • 💡 追加(メソッド) GetTellForJunction・MarkSolved・IsSolved:ReturnRoutineの後ろ、クラスの末尾。

追加箇所が散らばるので、全体を載せます。💡の付いた行が今回の追加分です。

// ==========================================================
// 📄 スクリプト:MazeDirector.cs(第7章時点・全体)
// ==========================================================
using System.Collections;
using System.Collections.Generic; // 💡 追加:Dictionary と HashSet を使うため
using UnityEngine;

// 迷路全体を統括するシングルトン
public class MazeDirector : MonoBehaviour
{
    // どこからでも1つの実体へアクセスできるようにする
    public static MazeDirector Instance { get; private set; }

    // プレイヤーの移動スクリプトへの参照
    [SerializeField] private PlayerLocomotion player;

    // 💡 追加:再抽選する確率(0〜1)。0.4なら4割の確率で引き直す
    [SerializeField, Range(0f, 1f)] private float relotteryChance = 0.4f;

    // 戻し処理中かどうかのフラグ(多重発火を防ぐ)
    private bool isReturning;

    // 外から読み取れるように公開する
    public bool IsReturning => isReturning;

    // 💡 追加:分岐ごとに前回の見分けポイントを覚える辞書(キー=分岐ID)
    private Dictionary<string, MimicTellData> records = new Dictionary<string, MimicTellData>();

    // 💡 追加:解決済みの分岐IDを覚える集合
    private HashSet<string> solved = new HashSet<string>();

    // シーン開始時にシングルトンを設定する
    private void Awake()
    {
        // すでに実体があれば自分を破棄する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        // 自分を唯一の実体として登録する
        Instance = this;
    }

    // 指定アンカーへプレイヤーをやさしく戻す(外部から呼ぶ入口)
    public void ReturnPlayer(Transform anchor)
    {
        // すでに戻し処理中なら無視する
        if (isReturning) return;
        // 戻しコルーチンを起動する
        StartCoroutine(ReturnRoutine(anchor));
    }

    // 霧で覆う→ワープ→霧を引く、の一連の流れ
    private IEnumerator ReturnRoutine(Transform anchor)
    {
        // 戻し処理中フラグを立てる
        isReturning = true;
        // プレイヤーの入力を止める(重力のみ働く状態)
        player.InputLocked = true;
        // 視界を覆うカーテンで包む
        FogController.Instance.FadeToVeil();
        // 霧が満ちるのを少し待つ
        yield return new WaitForSeconds(0.8f);
        // 霧が濃いうちに、プレイヤーをアンカーへワープさせる
        player.TeleportTo(anchor.position, anchor.rotation);
        // 霧を常時の薄霧へゆっくり戻す
        FogController.Instance.FadeToMin();
        // 霧が引きはじめてから少し待つ
        yield return new WaitForSeconds(0.5f);
        // 入力を再開する
        player.InputLocked = false;
        // 戻し処理中フラグを下ろす
        isReturning = false;
    }

    // 💡 追加:指定分岐の見分けポイントを返す(記憶があれば流用、なければ抽選)
    public MimicTellData GetTellForJunction(string junctionId, GameManager.DepthPhase phase)
    {
        // すでに記憶があり、かつ再抽選の判定を通らなかった場合は記憶を返す
        if (records.TryGetValue(junctionId, out var remembered)
            && Random.value >= relotteryChance)
        {
            // 覚えていた見分けポイントをそのまま返す
            return remembered;
        }
        // それ以外は新しく抽選して記憶を更新する
        var picked = TellLottery.Instance.Pick(phase);
        // 辞書に上書き保存する
        records[junctionId] = picked;
        // 新しい見分けポイントを返す
        return picked;
    }

    // 💡 追加:分岐を解決済みにする
    public void MarkSolved(string junctionId) => solved.Add(junctionId);

    // 💡 追加:分岐が解決済みかどうかを返す
    public bool IsSolved(string junctionId) => solved.Contains(junctionId);
}

■ Dictionary<string, MimicTellData>

Dictionaryは「キー」と「値」のペアを覚える箱です。ここではキーが分岐ID(文字列)、値が見分けポイント(MimicTellData)です。

■ TryGetValue は、存在チェックと取り出しを1回で

TryGetValue は「キーがあれば値を取り出してtrueを返し、なければfalseを返す」便利メソッドで、存在チェックと取得を1回で行えます。

🗄️ 実行中だけのデータです
・この記憶はゲームを終了すると消えます。ランタイム専用のデータなので、保存の仕組みは要りません。
・集めた秘宝や攻略の記録を「次の周回に残す」話は、第10弾(セーブ)で扱います。

分岐への再入場を検知する仕組み(JunctionTrigger)は、宝箱を配置する第10章で作ります。 「未解決の分岐に入ったらセットアップし直す」という処理なので、宝箱ペアが分岐に置かれてから作るほうが自然だからです。


■ 動作確認
この章では、見た目は何も変わりません。

内部の仕組みだけが整いました。確認するのはこの2点です

  • コンパイルが通ること(error CS0117 が出ていたら、TellLotteryのシングルトン化がまだです)。

  • MazeDirectorのインスペクターに Relottery Chance のスライダー(既定0.4)が現れていること。

📝 記憶ギミックが実際に効いているかは、宝箱を分岐に置く第10章で確認します。いま試したい方は、MazeDirectorに一時的な [ContextMenu] を仕込んで GetTellForJunction("J1", GameManager.Instance.CurrentPhase) を数回呼び、Consoleで「多くは同じ結果・たまに変わる」ことを確かめられます。


第8章 導きの精霊(救済)

設計

この章では、2つのオブジェクトに3つのスクリプトが分かれて載ります。参照も行き来するので、先に全体像を掴んでおきましょう。

役割分担はこうなっています。MazeDirector(常にアクティブ)には、司令塔のMazeDirector.csと、進捗のない時間を数えるProgressTracker.csが同居します。時間を数え続ける必要があるので、常に動いている場所に置く必要があるからです。

📝 矢印はすべて「インスペクターで割り当てる参照」です。この章のシーン構築で、この4本を手で繋いでいくことになります。図を見ながら「いまどの線を繋いでいるか」を意識すると、割り当ての意味が分かりやすくなります。

また進捗のない時間が45秒続けば、1に戻ります

一方のGuideSpiritは、ふだん非アクティブで眠っています(図で枠が破線になっているのがそれです)。ProgressTrackerがAppear() を呼んだときだけ目を覚まし、W1〜W10をたどって飛び、終点でまた眠りにつきます。


精霊の見た目

しばらく進めずにいるプレイヤーを、そっと助ける「導きの精霊」を作ります。ふわりと現れて正解ルートを飛んでいく、明るい光の球です。

まずは見た目の構築からしていきましょう。

ヒエラルキーで空オブジェクト GuideSpirit を作ります。その子にスフィア(3Dオブジェクト)を作り、スケール0.3にします。子スフィアのSphere Colliderは削除します(当たり判定は不要)。マテリアルには第4弾で作ったLanternGlowMatを適用します(過去資産の再利用です)。

GuideSpirit本体にライトを足します。「コンポーネントを追加」の検索窓に light と入力し、Light を選びます。追加したら、インスペクターの 型(Type)を「ポイント(Point)」に変更してください。続けて、色は暖色、強さ2、範囲5、モードは「リアルタイム」のままでOKです。


GuideSpirit(新規)

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

// 正解ルートを飛んで導く光の精霊
public class GuideSpirit : MonoBehaviour
{
    // 順にたどるウェイポイント(正解ルート)
    [SerializeField] private Transform[] waypoints;
    // 飛行速度
    [SerializeField] private float moveSpeed = 2.5f;
    // 精霊のポイントライト(消滅演出で減光する)
    [SerializeField] private Light spiritLight;
    // プレイヤーのTransform(近づいたら動き出す判定に使う)
    [SerializeField] private Transform player;

    // 現在たどっているウェイポイントの番号
    private int index;
    // 移動中かどうか
    private bool moving;
    // 消滅処理の最中かどうか(この間はUpdateを止める)
    private bool vanishing;

    // 出現させる(外部から呼ぶ)
    public void Appear()
    {
        // ウェイポイントが未設定なら、何もせず警告だけ出す
        if (waypoints == null || waypoints.Length == 0)
        {
            Debug.LogWarning("GuideSpirit: Waypointsが設定されていません");
            return;
        }
        // 開始位置を最初のウェイポイントに合わせる
        transform.position = waypoints[0].position;
        // インデックスを初期化する
        index = 0;
        // まだ移動は始めない(プレイヤーが近づいてから動く)
        moving = false;
        // 消滅処理中フラグも下ろしておく
        vanishing = false;
        // オブジェクトを有効化して姿を見せる
        gameObject.SetActive(true);
    }

    // 毎フレームの処理
    private void Update()
    {
        // 消滅処理の最中は何もしない(終点を越えて配列を読みにいくのを防ぐ)
        if (vanishing) return;

        // まだ動いていないなら、プレイヤーが近いか確認する
        if (!moving)
        {
            // プレイヤーとの距離の2乗を求める(sqrMagnitudeは平方根を使わず軽い)
            float sqr = (player.position - transform.position).sqrMagnitude;
            // 3m以内(3の2乗=9)に近づいたら移動を開始する
            if (sqr < 9f) moving = true;
            // まだ遠ければこのフレームは何もしない
            return;
        }

        // 目標のウェイポイント位置
        Vector3 target = waypoints[index].position;
        // 現在地から目標へ、行き過ぎない量だけ進む(MoveTowardsは目標を越えない)
        transform.position = Vector3.MoveTowards(
            transform.position, target, moveSpeed * Time.deltaTime);

        // 目標にほぼ到達したかを2乗距離で判定する
        if ((target - transform.position).sqrMagnitude < 0.01f)
        {
            // 次のウェイポイントへ進める
            index++;
            // 終点に達したら消滅演出を始める
            if (index >= waypoints.Length)
            {
                // 移動を止める
                moving = false;
                // 消滅処理に入ったことを記録する(これでUpdateが止まる)
                vanishing = true;
                // フェード消滅コルーチンを起動する
                StartCoroutine(FadeOut());
            }
        }
    }

    // ふわっと消えるコルーチン
    private IEnumerator FadeOut()
    {
        // 経過時間を測る変数
        float t = 0f;
        // 開始時のスケールと光量を記録する
        Vector3 startScale = transform.localScale;
        float startIntensity = spiritLight != null ? spiritLight.intensity : 0f;
        // 0.5秒間ループする
        while (t < 0.5f)
        {
            // 経過時間を加算する
            t += Time.deltaTime;
            // 0→1へ進む割合
            float k = t / 0.5f;
            // スケールを0へ向けて縮める
            transform.localScale = Vector3.Lerp(startScale, Vector3.zero, k);
            // ライトの強さも0へ減らす
            if (spiritLight != null) spiritLight.intensity = Mathf.Lerp(startIntensity, 0f, k);
            // 次のフレームまで待つ
            yield return null;
        }
        // 見た目を元へ戻してから非アクティブ化する(次回の再利用のため)
        transform.localScale = startScale;
        if (spiritLight != null) spiritLight.intensity = startIntensity;
        // 姿を消す
        gameObject.SetActive(false);
    }
}
  • sqrMagnitude は距離の2乗を返します。距離そのもの(magnitude)は平方根の計算を含むため少し重く、単に「近いか遠いか」を比べるだけなら2乗同士で比較すれば十分です。だから軽い方を使っています。

  • Vector3.MoveTowards は、現在地から目標へ、指定した最大距離(maxDistanceDelta)ぶんだけ進み、目標を行き過ぎない仕様です。Unity公式APIリファレンスの説明では、現在地がすでに目標よりmaxDistanceDeltaより近ければ、戻り値は目標そのものになり、決してオーバーシュート(行き過ぎ)しない、とされています。だから終点でぴたりと止まります。

  • 精霊は浮遊する光の球なので、重力もNavMesh(経路探索の地面情報)も不要です。第9弾の番人ゴーレムはNavMeshを使って賢く歩きますが、こちらは対比の布石です。


ProgressTracker(新規・MazeDirectorと同じオブジェクトへ)

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

// 一定時間進捗がなければ精霊を呼ぶ見張り役
public class ProgressTracker : MonoBehaviour
{
    // 呼び出す精霊への参照
    [SerializeField] private GuideSpirit spirit;
    // 無進捗と判断するまでの秒数
    [SerializeField] private float waitSeconds = 45f;

    // 最後に進捗があった時刻
    private float lastProgressTime;

    // 開始時に時刻を初期化する
    private void Start() => lastProgressTime = Time.time;

    // 毎フレーム、無進捗が続いていないか確認する
    private void Update()
    {
        // 精霊が出ていないとき、かつ待ち時間を超えたら
        if (!spirit.gameObject.activeSelf
            && Time.time - lastProgressTime > waitSeconds)
        {
            // 精霊を出現させる
            spirit.Appear();
            // タイマーをリセットする
            lastProgressTime = Time.time;
        }
    }

    // 進捗があったことを外部から知らせる(タイマーが戻る)
    public void NotifyProgress() => lastProgressTime = Time.time;
}

MazeDirectorに進捗の中継を足す(完成形)

司令塔にも、進捗を中継する橋渡しを1つ用意します(DepthTriggerや、第10章の正解時から呼ばれます)。追加は2箇所で、フィールドは relotteryChance の下、メソッドはクラスの末尾(IsSolved の後ろ)です。

これでMazeDirector.csは完成形になるので、全体を載せます。💡が今回の追加分です。

// ==========================================================
// 📄 スクリプト:MazeDirector.cs(第8章時点・完成形)
// ==========================================================
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 迷路全体を統括するシングルトン
public class MazeDirector : MonoBehaviour
{
    // どこからでも1つの実体へアクセスできるようにする
    public static MazeDirector Instance { get; private set; }

    // プレイヤーの移動スクリプトへの参照
    [SerializeField] private PlayerLocomotion player;

    // 再抽選する確率(0〜1)。0.4なら4割の確率で引き直す
    [SerializeField, Range(0f, 1f)] private float relotteryChance = 0.4f;

    // 💡 追加:進捗の見張り役への参照
    [SerializeField] private ProgressTracker progress;

    // 戻し処理中かどうかのフラグ(多重発火を防ぐ)
    private bool isReturning;

    // 外から読み取れるように公開する
    public bool IsReturning => isReturning;

    // 分岐ごとに前回の見分けポイントを覚える辞書(キー=分岐ID)
    private Dictionary<string, MimicTellData> records = new Dictionary<string, MimicTellData>();

    // 解決済みの分岐IDを覚える集合
    private HashSet<string> solved = new HashSet<string>();

    // シーン開始時にシングルトンを設定する
    private void Awake()
    {
        // すでに実体があれば自分を破棄する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        // 自分を唯一の実体として登録する
        Instance = this;
    }

    // 指定アンカーへプレイヤーをやさしく戻す(外部から呼ぶ入口)
    public void ReturnPlayer(Transform anchor)
    {
        // すでに戻し処理中なら無視する
        if (isReturning) return;
        // 戻しコルーチンを起動する
        StartCoroutine(ReturnRoutine(anchor));
    }

    // 霧で覆う→ワープ→霧を引く、の一連の流れ
    private IEnumerator ReturnRoutine(Transform anchor)
    {
        // 戻し処理中フラグを立てる
        isReturning = true;
        // プレイヤーの入力を止める(重力のみ働く状態)
        player.InputLocked = true;
        // 視界を覆うカーテンで包む
        FogController.Instance.FadeToVeil();
        // 霧が満ちるのを少し待つ
        yield return new WaitForSeconds(0.8f);
        // 霧が濃いうちに、プレイヤーをアンカーへワープさせる
        player.TeleportTo(anchor.position, anchor.rotation);
        // 霧を常時の薄霧へゆっくり戻す
        FogController.Instance.FadeToMin();
        // 霧が引きはじめてから少し待つ
        yield return new WaitForSeconds(0.5f);
        // 入力を再開する
        player.InputLocked = false;
        // 戻し処理中フラグを下ろす
        isReturning = false;
    }

    // 指定分岐の見分けポイントを返す(記憶があれば流用、なければ抽選)
    public MimicTellData GetTellForJunction(string junctionId, GameManager.DepthPhase phase)
    {
        // すでに記憶があり、かつ再抽選の判定を通らなかった場合は記憶を返す
        if (records.TryGetValue(junctionId, out var remembered)
            && Random.value >= relotteryChance)
        {
            // 覚えていた見分けポイントをそのまま返す
            return remembered;
        }
        // それ以外は新しく抽選して記憶を更新する
        var picked = TellLottery.Instance.Pick(phase);
        // 辞書に上書き保存する
        records[junctionId] = picked;
        // 新しい見分けポイントを返す
        return picked;
    }

    // 分岐を解決済みにする
    public void MarkSolved(string junctionId) => solved.Add(junctionId);

    // 分岐が解決済みかどうかを返す
    public bool IsSolved(string junctionId) => solved.Contains(junctionId);

    // 💡 追加:進捗があったことを見張り役へ中継する
    public void NotifyProgress()
    {
        // タイマーをリセットさせる(見張り役が未設定でもエラーにならない)
        if (progress != null) progress.NotifyProgress();
    }
}

■ なぜ中継役を1枚挟むのか?
 
進捗を知らせたい側(DepthTriggerや、第10章のChestPairController)から見ると、MazeDirector.Instance.NotifyProgress() と書くだけで済みます。ProgressTrackerを自分で探しに行く必要がありません。司令塔が窓口を一本化している、という形です。


シーン構築

■ GuideSpiritオブジェクト(その1:アタッチ/割り当て)

  1. ヒエラルキーで GuideSpirit を選択し、「コンポーネントを追加」から GuideSpirit を検索してアタッチします。

  2. 現れた Player 欄にプレイヤー(model_test)を、Spirit Light 欄にライトを割り当てます(本体に直接Lightを付けた方は自分自身、子オブジェクトとして作った方はその子を割り当てます)。Waypoints は次の手順で設定するので、いまは空のままで構いません。

  3. 最後に、インスペクター左上のチェックボックスを外して非アクティブにします(Appear() が呼ばれたときに、はじめて姿を現すためです)。


■ MazeDirectorオブジェクト

  1. ProgressTracker.cs を MazeDirector オブジェクトにアタッチします。

  2. 現れた Spirit 欄にGuideSpiritを割り当てます。

  3. 上のMaze Directorコンポーネントにある「進捗(Progress)」欄へ、ヒエラルキーの MazeDirector(自分自身)をドラッグします。


■ GuideSpirit(その2:waypoints配列)

waypoints配列(Size 10)に、空オブジェクト W1〜W10 を割り当てます。まず空オブジェクトを作って W1 に改名し、位置を入力する……という手順を1つ丁寧に行い、残りは表を見ながら同様に作ります。座標は正解ルートをなぞります。

Yはすべて1.5で共通です。※ 床の高さを調整した方は、その値に読み替えてください(筆者場合は1.55)

💡 Yはすべて共通なので、W1を作って値を入れたら、複製(Cmd+D)していくとYの入力忘れを防げます。名前と、XとZだけ変えていけばOKです。特にW2とW3、W5とW6、W8とW9はZが同じなので、Xを0⇄4に変えるだけで済みます。

  • 曲がる地点(W3・W6・W9)でXが 0⇄4 に切り替わっているのが、右・左・右の正解ルートです

  • 精霊は W1 から順に飛び、W10 に着くとふわりと消えます


■ 動作確認

  • 進捗のない状態が45秒続くと、精霊が現れること。
    (※ テスト時に45秒が億劫なら、MazeDirectorオブジェクトのインスペクターにあるWait Seconds を下げて調整してください)

  • 精霊に近づくと(3m以内)、正解ルートを飛んでいくこと。

  • 終点でふわっと消えること。

精霊が現れて、消えて、また現れるまで

🧚 精霊について
W1からW10、つまり入口側から宝物庫の手前まで飛びます。
終点に着くとふわりと消え、また進捗のない状態が『45秒(Wait Seconds)』続くとW1(直線Aの中ほど)に現れます。ただし、プレイヤーが3m以内に近づくまでは、その場で待機したままです。


第9章 フェーズ結線(エンジンにアクセルを繋ぐ)

探索フェーズの自動化

第7弾でインスペクターに入力した「フェーズ別の重み」が、ここではじめて意味を持ちます

第7弾では、探索フェーズ(Shallow/Middle/Deep)をインスペクターで手動切り替えしていました。「自動化は第8弾で」と予告していた、その回収です。奥へ進むとフェーズが自動で上がるよう、いわば「エンジンにアクセルを繋ぐ」作業をします。


GameManagerを改修する

第7弾で作ったGameManager.csに、2つの機能を足します。

  • 💡 追加 AdvancePhase():フェーズを1段階進めるメソッド。Deepで頭打ちになり、それ以上は上がりません。

  • 💡 追加 TreasureCount と AddTreasure():集めた秘宝の数と、それを増やすメソッド。使うのは第10章(正解の宝箱を開けたとき)ですが、フェーズと並ぶ「ゲーム全体の状態」なので、ここでまとめて足しておきます。

追加位置は、プロパティを既存の MistakeCount の隣に、メソッドを AddMistake() の後ろに置くと整理されます。全体を載せます。

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

// ゲーム全体の進行状態を1か所で預かる管理役(今回は簡易版)。
// 作りは第4弾のFogControllerと同じ「シーン内シングルトン」です。
public class GameManager : MonoBehaviour
{
    // どこからでも GameManager.Instance で呼べるようにする
    public static GameManager Instance { get; private set; }

    // 遺跡の深さを表すフェーズ。enumは「決まった選択肢から1つ選ぶ」型
    public enum DepthPhase
    {
        Shallow, // 浅層
        Middle,  // 中層
        Deep     // 深層
    }

    // [SerializeField]付きなので、Play中でもインスペクターから切り替えられる
    [SerializeField] private DepthPhase currentPhase = DepthPhase.Shallow;

    // 外からは読み取り専用で公開(勝手に書き換えられないように)
    public DepthPhase CurrentPhase => currentPhase;

    // 失敗(偽物を開けてしまった)回数
    public int MistakeCount { get; private set; }

    // 💡 追加:集めた秘宝の数(読み取り専用)
    public int TreasureCount { get; private set; }

    // 深層では判定が反転する(違和感のある方こそ本物)というルール
    public bool IsJudgementInverted => currentPhase == DepthPhase.Deep;

    // フェーズの日本語表示名。C#の「switch式」で書いています
    public string PhaseLabel => currentPhase switch
    {
        DepthPhase.Shallow => "浅層",
        DepthPhase.Middle  => "中層",
        DepthPhase.Deep    => "深層",
        _ => "不明" // どれにも当てはまらない場合の保険
    };

    private void Awake()
    {
        // シングルトンの基本形(第4弾のFogControllerと同じ)
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
    }

    // 失敗を1つ数える。外からはこのメソッド経由でしか増やせません
    public void AddMistake()
    {
        MistakeCount++;
    }

    // 💡 追加:フェーズを1段階進める(Deepで頭打ち)
    public void AdvancePhase()
    {
        // 現在のフェーズに応じて次へ進める
        if (currentPhase == DepthPhase.Shallow) currentPhase = DepthPhase.Middle;
        else if (currentPhase == DepthPhase.Middle) currentPhase = DepthPhase.Deep;
        // Deepならそれ以上は上がらない(何もしない)
    }

    // 💡 追加:秘宝を1つ増やす
    public void AddTreasure()
    {
        // カウントを加算する
        TreasureCount++;
    }
}

📝 TreasureCount を MistakeCount の隣に置いたのは、どちらも「外から読むだけのカウンター」で性質が同じだからです。同じ種類のものが並んでいると、後から読み返したときに探しやすくなります。同じく AdvancePhase()と AddTreasure() は「外部から状態を変える窓口」なので、AddMistake() の後ろにまとめました。


DepthTrigger(新規)

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

// 通過するとフェーズを1段階上げるワンショットのトリガー
public class DepthTrigger : MonoBehaviour
{
    // 何かがトリガーに入ったとき
    private void OnTriggerEnter(Collider other)
    {
        // プレイヤー以外は無視する
        if (other.GetComponent<CharacterController>() == null) return;
        // フェーズを1段階進める
        GameManager.Instance.AdvancePhase();
        // 進捗を通知して精霊タイマーを戻す
        MazeDirector.Instance.NotifyProgress();
        // 一度きりにするため自分を無効化する
        gameObject.SetActive(false);
    }
}

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

ワンショット(発火後SetActive(false))にしているので、戻されて再び同じ地点を通過しても、フェーズが二重に上がることはありません。フェーズは前進のみです。


PhaseDebugHUDを改修する

第7弾で作った開発用HUDに、秘宝の行を足します。変更はUpdate内の表示文字列だけですが、書き換え箇所が分かりにくいので全体を載せます。

// ==========================================================
// 📄 スクリプト:PhaseDebugHUD.cs(改修後・全体)
// ==========================================================
using UnityEngine;
using TMPro;

// 現在のフェーズ・失敗数・秘宝の数を画面の隅に出しておく、開発用のHUD。
public class PhaseDebugHUD : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI label; // 表示先のTMP

    void Update()
    {
        // GameManagerをシーンに置き忘れていても、エラーで止まらない保険
        if (GameManager.Instance == null) return;

        // $"..." は文字列補間。{ } の中の値が文字列に埋め込まれます
        label.text =
            $"フェーズ: {GameManager.Instance.PhaseLabel}\n" +
            $"失敗: {GameManager.Instance.MistakeCount}\n" +
            // 💡 追加:秘宝の行
            $"秘宝: {GameManager.Instance.TreasureCount} / 3";
    }
}

GameManager.TreasureCount と書くとコンパイルエラー(CS0120)になりますので、Instance を挟むのを忘れないでください。

static が付いているのは Instance プロパティだけで、TreasureCount や MistakeCount はインスタンスのメンバーだからです。「クラスそのもの」ではなく「シーンに置いた実体」を指定する必要がある、というわけですね。既存の2行が GameManager.Instance.〜 になっているので、それに揃えれば間違いありません。


■ UI手順

ヒエラルキーでPhaseDebugTextを選択し、Rect Transformの高さを70→100に変更します(3行になるため)。アンカーと位置はそのままでOKです。


シーン構築

空オブジェクトを2つ作り、DepthTrigger① を (4, 1.2, 22)、DepthTrigger② を (0, 1.2, 36) に置きます。Box Colliderを「追加」し、Is Trigger をオンにして、サイズ(Size)を 3.5 / 2.5 / 1 にします(Transformのスケールではありません)。レイヤーはDefaultのままです。最後にDepthTrigger.csをアタッチします。

💡 DepthTrigger①を最後まで作ってから、複製(Cmd+D)して②にすると効率的です。サイズ・Is Trigger・レイヤー・スクリプトのアタッチまで、すべて引き継がれます。変えるのは名前と位置だけです。

①は直線Bの中、②は直線Cの中にあります。どちらも南北に伸びる通路なので、回転は0のままでOKです(厚み1mの面が、通路を横切る関所になります)。深度トリガーはワンショットなので、発火後は自動で無効化されます


■ フェーズは、戻っても下がりません

本作においてフェーズとは、現在地ではなく「どこまで到達したか」を表しているからです

深層まで進んだあとスタート地点へ引き返しても、表示は「深層」のままです。これは仕様です。フェーズは「いま遺跡のどこにいるか」ではなく、「どこまで到達したか」を表しているからです。深層の掟を知り、反転のルールを体験したプレイヤーが、入口に戻った瞬間にそれを忘れてしまう……というのも不自然でしょう。ゲームの進行度、いわば「章が進んだ」という状態に近い扱いです。

💡 もし場所に連動させたいなら、AdvancePhase() の代わりに「指定したフェーズにする」メソッドを作り、逆方向にもトリガーを置いて上げ下げすることになります。ただしワンショットをやめる必要があり、トリガーの数も倍になります。本連載では、シンプルな前進のみを採用しました。


■ 動作確認

  • 正解ルートを進むと、HUDが浅層→中層→深層と変わること。

  • 戻されて再通過しても、フェーズが二重に上がらないこと。

  • 深層になると、次章で判定が反転する土台が整うこと。


第10章 宝箱ペアを分岐に置く(すべてを繋ぐ)

宝箱ペアを分岐に置く

いよいよ、これまで作ったパーツをすべて繋ぎます。第7弾の宝箱ペアを改修し、迷路の分岐に3体配置します。


TreasureChestを改修する

解決済みの宝箱を「もう調べられないもの」にするための改修です。追加は2つだけで、既存の処理には手を触れません。

レイヤーを切り替えるだけで実現しています
  • 💡 追加(フィールド) bodyCollider:箱本体のコライダー参照。「部品の参照」欄の末尾に足します。

  • 💡 追加(メソッド) SetExaminable(bool on):レイヤーを切り替えて、調べられる/られないを変えます。Open() の後ろあたりに置きます。

// ==========================================================
// 📄 スクリプト:TreasureChest.cs(改修後・全体)
// ==========================================================
using UnityEngine;
using TMPro;

// 宝箱1個ぶんの「見た目の操作」と「調べられる口(IInteractable)」を担当。
// どちらが本物か、という判定はChestPairControllerに任せます。
public class TreasureChest : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "開ける"; // "[E] 開ける" に使われる

    [Header("部品の参照")]
    [SerializeField] private GameObject lid;          // フタ
    [SerializeField] private GameObject treasure;     // 中身(初期非表示)
    [SerializeField] private Renderer[] eyeRenderers; // 目(左右2つ)
    [SerializeField] private TextMeshPro plateText;   // 銘板(3D TMP)
    [SerializeField] private Transform propSocket;    // 飾りの取り付け位置
    // 💡 追加:箱本体のコライダー(レイヤーを切り替える対象)
    [SerializeField] private Collider bodyCollider;

    [Header("目のマテリアル(アセットを2つ割り当て)")]
    [SerializeField] private Material eyeNormalMat;   // ふだんの目
    [SerializeField] private Material eyeGlowMat;     // 光る目(放出オン済み)

    [Header("ペアの進行役")]
    [SerializeField] private ChestPairController pairController;

    private float baseRotationY;    // 最初の向きを覚えておくメモ
    private GameObject spawnedProp; // Instantiateした飾り(後片付け用)

    private void Awake()
    {
        baseRotationY = transform.localEulerAngles.y; // 初期の向きを記憶
    }

    // ---- IInteractable(第5弾で決めた共通の約束)----

    public string GetPrompt() => prompt; // Interactorが "[E] {これ}" に使う

    public void Interact()
    {
        pairController.Judge(this); // 「私が開けられました」と進行役へ報告
    }

    // ---- ここから下は、進行役から呼ばれる「見た目の操作」----

    // 差分なしのまっさらな状態へ戻す
    public void ResetView(string basePlate)
    {
        // 向きを初期値へ(localEulerAnglesで角度を「絶対指定」しています)
        transform.localEulerAngles = new Vector3(0f, baseRotationY, 0f);
        SetEyesGlow(false);          // 目を消す
        plateText.text = basePlate;  // 銘板を基本の文言に
        lid.SetActive(true);         // フタを閉じる
        treasure.SetActive(false);   // 中身を隠す

        if (spawnedProp != null)     // 前回の飾りが残っていたら
        {
            Destroy(spawnedProp);    // 破棄して片付ける
            spawnedProp = null;
        }
    }

    // 見分けポイントを自分に適用する(=この箱が「違和感のある方」になる)
    public void ApplyTell(MimicTellData tell, string basePlate)
    {
        switch (tell.tellType)
        {
            case TellType.Rotation:
                // 初期の向きに、SOで指定されたぶんのズレを足す
                transform.localEulerAngles =
                    new Vector3(0f, baseRotationY + tell.rotationOffsetY, 0f);
                break;

            case TellType.EyeGlow:
                SetEyesGlow(true); // 目を光らせる
                break;

            case TellType.Prop:
                if (tell.propPrefab != null)
                {
                    // 親(ソケット)を指定してInstantiate。
                    // この2引数版は生成物を「親のローカル座標」で置きます。
                    // Prefab側の位置を(0,0,0)にしておいたのはこのためです
                    spawnedProp = Instantiate(tell.propPrefab, propSocket);
                }
                break;

            case TellType.PlateText:
                // 1文字違いの文言に差し替え。textへの代入だけで、
                // 次の描画から自動で反映されます(ForceMeshUpdateは不要)
                plateText.text = tell.fakePlateText;
                break;
        }
    }

    // 正解演出:フタを外して中身を見せる(簡易版。演出の作り込みは第11弾で)
    public void Open()
    {
        lid.SetActive(false);
        treasure.SetActive(true);
    }

    // 💡 追加:この箱を「調べられるもの」にするか、外すか
    public void SetExaminable(bool on)
    {
        // onならInteractable、offならDefaultへレイヤーを切り替える
        bodyCollider.gameObject.layer = on
            ? LayerMask.NameToLayer("Interactable")
            : LayerMask.NameToLayer("Default");
    }

    // 目のマテリアルをまるごと「差し替える」。
    // sharedMaterialへの差し替え(参照の付け替え)なので安全です。
    private void SetEyesGlow(bool on)
    {
        foreach (var r in eyeRenderers)
        {
            r.sharedMaterial = on ? eyeGlowMat : eyeNormalMat;
        }
    }
}

これは第5弾のレイヤー知識の再利用です。InteractorのRaycastはInteractableレイヤーだけを拾い、Defaultは素通しします。だから解決済みのペアをDefaultへ戻すと、レイが箱を素通りして「調べる」プロンプトが出なくなります。開いた宝箱は「もう調べられないランドマーク(目印)」として残り、来た道の目印になります。

■ Body Collider 欄への割り当て

追加した Body Collider 欄への割り当てを忘れないでください。ChestA・ChestBそれぞれのインスペクターで、子のBody(コライダーが付いているオブジェクト)をドラッグします。ここが空のままだと、正解した瞬間に NullReferenceException が出ます。


ChestPairControllerを改修する

第7弾で作った宝箱ペアの司令役を、迷路に組み込める形へ改修します。この章でいちばん手を入れるスクリプトです。変更は5点あります。

ChestPairController.cs の改修 5点
  • 💡 追加(フィールド) enum CorrectSide { Random, A, B } と correctSide/junctionId(分岐の識別子)/returnAnchor(不正解時の戻り先)/IsSolved(解決済みか)。

  • 💡 書き換え(参照) lottery と feedbackUI のSerializeField参照を撤去し、TellLottery.Instance と JudgeFeedbackUI.Instance を使う形にします。Prefab化するとシーン内への参照が切れるためで、第7章でシングルトンに昇格させたのは、まさにこの場面のためでした。

  • 💡 書き換え(セットアップ) Setup() を public void SetupFromDirector() に整理します。解決済みなら何もせず、junctionId が空なら第7弾どおりの単体抽選、入っていればMazeDirector経由で記憶ギミックを使います。

  • 💡 書き換え(判定) Judge() に「解決済みなら無視」「戻し処理中なら無視」のガードを足し、正解・不正解それぞれの処理を新しい仕様にします。

  • 💡 削除 ResetRoutine()(3秒後に元へ戻す処理)。正解した宝箱は開けっ放しでランドマークにするので、不要になりました。

改修が多いので、全体を載せます。

// ==========================================================
// 📄 スクリプト:ChestPairController.cs(改修後・全体)
// ==========================================================
using UnityEngine;

// 一対の宝箱を統括するコントローラ
public class ChestPairController : MonoBehaviour
{
    // 差分をどちら側に付けるかの決め方
    public enum CorrectSide { Random, A, B }

    // 左の箱
    [SerializeField] private TreasureChest chestA;
    // 右の箱
    [SerializeField] private TreasureChest chestB;
    // 正解ルート側(この迷路での正しい道)
    [SerializeField] private CorrectSide correctSide = CorrectSide.Random;
    // この分岐の識別子(記憶ギミック用)
    [SerializeField] private string junctionId;
    // 不正解時にプレイヤーを戻すアンカー
    [SerializeField] private Transform returnAnchor;
    // 基準の銘板文字列
    [SerializeField] private string basePlateText = "めざめのたから";

    // 差分が仕込まれた箱
    private TreasureChest chestWithTell;
    // 今回選ばれた差分データ
    private MimicTellData currentTell;
    // 判定処理中かどうか
    private bool isResolving;
    // この分岐が解決済みか(読み取り専用)
    public bool IsSolved { get; private set; }

    // シーン開始時にセットアップする
    private void Start() => SetupFromDirector();

    // 司令塔の記憶を反映してセットアップする
    public void SetupFromDirector()
    {
        // 解決済みなら何もしない(開けっ放しのランドマークを維持)
        if (IsSolved) return;

        // 両方の箱を基準状態に戻す
        chestA.ResetView(basePlateText);
        chestB.ResetView(basePlateText);

        // 見分けポイントを決める:junctionIdが空なら単体抽選(第7弾互換)
        if (string.IsNullOrEmpty(junctionId))
            currentTell = TellLottery.Instance.Pick(GameManager.Instance.CurrentPhase);
        else
            // 分岐IDがあれば司令塔経由で記憶ギミックを使う
            currentTell = MazeDirector.Instance.GetTellForJunction(
                junctionId, GameManager.Instance.CurrentPhase);

        // 深層かどうか(判定反転フラグ)
        bool inverted = GameManager.Instance.IsJudgementInverted;

        // 差分を付ける箱を決める
        TreasureChest tellSide;
        if (correctSide == CorrectSide.Random)
        {
            // Randomならコイントスで決める(第7弾互換)
            // 📝 Random.valueは0.0〜1.0の両端を含む
            tellSide = Random.value < 0.5f ? chestA : chestB;
        }
        else
        {
            // 正解側の箱(A/B指定)を求める
            TreasureChest correctChest = (correctSide == CorrectSide.A) ? chestA : chestB;
            // 不正解側の箱
            TreasureChest wrongChest = (correctSide == CorrectSide.A) ? chestB : chestA;
            // 深層(inverted)なら正解側に差分、通常なら不正解側に差分を付ける
            tellSide = inverted ? correctChest : wrongChest;
        }

        // 決めた箱に差分を適用し、参照を覚えておく
        chestWithTell = tellSide;
        chestWithTell.ApplyTell(currentTell, basePlateText);
    }

    // どちらかの箱が開けられたときに呼ばれる判定
    public void Judge(TreasureChest picked)
    {
        // すでに解決済み、または判定処理中、または戻し処理中なら無視する
        if (IsSolved || isResolving || MazeDirector.Instance.IsReturning) return;

        // 選んだ箱に差分があったか
        bool pickedHasTell = (picked == chestWithTell);
        // 深層なら判定を反転する
        bool inverted = GameManager.Instance.IsJudgementInverted;
        
        // 差分のない「きれいな箱」を選んだか
        bool pickedClean = !pickedHasTell;
        // 通常はきれいな箱が正解。深層では反転して、違和感のある箱が正解になる
        bool correct = pickedClean ^ inverted;


        if (correct)
        {
            // 判定処理中フラグ(このフレームの二重判定を防ぐ)
            isResolving = true;
            // 選んだ箱を開ける
            picked.Open();
            // この分岐を解決済みにする
            IsSolved = true;
            // 秘宝を1つ増やす
            GameManager.Instance.AddTreasure();
            // 司令塔にこの分岐の解決を記録する
            MazeDirector.Instance.MarkSolved(junctionId);
            // 両方の箱を「調べられるもの」から卒業させる
            chestA.SetExaminable(false);
            chestB.SetExaminable(false);
            // 進捗を通知して精霊タイマーを戻す
            MazeDirector.Instance.NotifyProgress();
            // 成功フィードバックを表示する
            JudgeFeedbackUI.Instance.Show(true, "あたり!");
        }
        else
        {
            // 失敗回数を増やす
            GameManager.Instance.AddMistake();
            // 失敗フィードバックを表示する
            JudgeFeedbackUI.Instance.Show(false, "はずれ……");
            // プレイヤーをやさしく手前へ戻す
            MazeDirector.Instance.ReturnPlayer(returnAnchor);
        }
    }

    // 単体シーンで試したい場合のテスト用
    [ContextMenu("セットアップし直す")]
    private void DebugSetup() => SetupFromDirector();
}

ResetRoutine(3秒後リセット)を削除したので、第7弾の単体シーンで試したい場合は、上の[ContextMenu]「セットアップし直す」からSetupFromDirectorを呼べます。


第7弾のJudge()の訂正

ここで1点、お詫びと訂正があります。第7弾で掲載したJudge()の判定式に誤りがありました。

// ❌ 第7弾の掲載コード(誤り)
bool correct = pickedHasTell ^ inverted;

pickedHasTell は「差分のある箱を選んだか」を表す変数なので、この式だと通常フェーズで「違和感のある箱を開けたら正解」という、意図とは真逆の判定になっていました。正しくは、差分のない箱を基準にします。

// ✅ 正しい判定
bool pickedClean = !pickedHasTell;
bool correct = pickedClean ^ inverted;

上に掲載した全体コードは、この修正を反映済みです。第7弾から続けて作っている方は、この2行に差し替えてください。

症状としては、4通りすべてが逆になります。「浅層で違和感のある箱が正解になる」「深層で違和感のある箱が失敗になる」といった具合です。今回は宝箱と通路が連動したことで食い違いが目に見えるようになり、発覚しました。

第7弾の単体テストでは、どちらが正解かを人間が決めていたぶん、気づきにくい誤りでした。真理値表を書いて4通りを確かめる——という基本の大切さを、身をもって学んだ次第です。


JunctionTrigger(新規)— 分岐への再入場を検知する

第7章で作った記憶ギミックを呼び出す係です。プレイヤーが未解決の分岐に入ってきたら、ペアをセットアップし直します。IsSolved と SetupFromDirector が揃ったいまなら、そのまま動きます。

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

// 分岐への再入場を検知するトリガー
public class JunctionTrigger : MonoBehaviour
{
    // この分岐に対応する宝箱ペア
    [SerializeField] private ChestPairController pair;

    // 何かがトリガーに入ったとき
    private void OnTriggerEnter(Collider other)
    {
        // プレイヤー(CharacterController持ち)以外は無視する
        if (other.GetComponent<CharacterController>() == null) return;
        // 未解決の分岐なら、記憶ギミックを反映してセットアップし直す
        if (pair != null && !pair.IsSolved)
        {
            pair.SetupFromDirector();
        }
    }
}

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

■ シーン構築

  1. 各T字(分岐①〜③)の中心に空オブジェクトを作り、名前を JunctionTrigger①〜③ にします。位置はT字と同じXZ・Y1.2(例:(0, 1.2, 18) / (4, 1.2, 32) / (0, 1.2, 46))。※環境に合わせ適宜調整

  2. Box Colliderを「追加」し、Is Trigger をオンにします。サイズ(Size)を 3.5 / 2.5 / 3.5 にします(分岐の空間をほぼ覆う大きさ。Transformのスケールではありません)。

  3. レイヤーはDefaultのままにします。

  4. JunctionTrigger.csをアタッチします(pair の割り当ては、次項『Prefab化と配置手順』のあとで行います)。

📝 このトリガーは分岐に入るたびに発火しますが、解決済みのペアは IsSolved で弾かれるので、何度通っても開いた宝箱がリセットされることはありません。


Prefab化と配置手順

作業は「共通するものはPrefabに、1体ずつ違うものはインスタンスに」という分担で進めます。第3弾で区画Prefabを作ったときと同じ考え方です(形はPrefab、置く場所は1つずつ)。

■ 手順1:Prefab化する前に、共通の設定を整える
シーンにある既存のChestPairを選択し、3体すべてに共通する項目を先に設定します。ここで整えておけば、あとで3回入力する手間が省けます。

ChestPairControllerの Chest A Chest B、Base Plate Text(「めざめのたから」)

ChestA・ChestBそれぞれの Body Collider 欄に、子のBody(コライダーが付いているオブジェクト)を割り当て。
※ 各箱の子Bodyのレイヤーが Interactable のままであることも確認。

📝 junctionId・correctSide・returnAnchor は、この段階では空のままで構いません。3体それぞれ違う値になるので、配置してから1体ずつ設定します。

■ 手順2:Prefab化する

ChestPairをヒエラルキーから、Projectウィンドウの Assets > Prefabs フォルダへドラッグします(第3弾で区画Prefabを保存したのと同じ場所です)。名前が青くなればPrefab化の成功です。

■ 手順3:分岐①〜③へ3体配置する
Prefabをシーンへ3体ドラッグし、それぞれの位置と回転を設定します。

Prefabを3体配置し、それぞれに違う値を入れます

分岐① (0, 1.0, 19.2)
分岐② (4, 1.0, 33.2)
分岐③ (0, 1.0, 47.2)

各T字の奥壁から0.8m手前の位置です。Y=1.0の浮かせ配置は第7弾どおりで、目線とレイの高さに合わせてあります。目と銘板が入口側(-Z)を向くよう、適宜確認しながら回転Yも調整してください。

⚠️ 第7弾で触れた「箱の正面はどちらか」の罠に注意です。シーンビューで、手前から見て顔が見えるかを確認しましょう。

■ 手順4:3体それぞれに、固有の値を入れる

配置した3体のインスペクターに、分岐ごとの設定を入力します。

■ 手順5:JunctionTriggerと結線する

JunctionTrigger①〜③の Pair 欄に、対応するChestPairを割り当てます。①にはJ1のペア、②にはJ2、③にはJ3、という具合です。

⚠️ Prefab化したあと、インスタンス側で値を変えると、その項目が太字で表示されます。これは「Prefabとは違う値です」という印(オーバーライド)で、正常な状態です。「Prefabに適用」は押さないでください。 押すと、その値が3体すべてに広がってしまいます。

🧩 XOR整合の解説
第7弾で書いたJudge()の核心は pickedHasTell ^ inverted(XOR)の1行でした。今回、この1行はまったく書き換えていません。データ(correctSide)と配置だけでルールが拡張されています。通常フェーズでは、差分側の箱が偽物なので、「きれいな箱の側の道が正解」です。深層フェーズでは反転し、「違和感(ほころび)のある箱の側こそ正解の道」になります。石碑の「綻びこそ真なり」が、まさにこのことです。ロジックは一切いじらず、データと配置だけでルールを広げた——第7弾から続く「データ駆動」の実践です。


総合動作確認(8つのチェック)

入口から宝物庫まで、実際に歩いて確かめます

・⑥は確率的な挙動なので、何度か往復して確かめてください(4割ほどの確率で変わります)
・⑦の分岐③は深層なので、それまでとは逆になります。「きれいな箱の側」を選ぶと不正解です

  1. 各分岐に着くと、片方の箱にだけ差分が付いている

  2. 正解の箱を開けると開いたまま残り、HUDが「秘宝: 1 / 3」になる

  3. 解決済みペアは「調べる」プロンプトが出ない

  4. 偽物を開けると「はずれ……」→霧→戻される

  5. 不正解の道を強行しても、奥で戻される

  6. 戻って再訪すると(未解決なら)たまに差分が変わる

  7. 深層(分岐③)では違和感のある側が正解=反転を体感できる

  8. 秘宝3つで宝物庫に到達=完走できる

📝 宝箱を開けずに正解ルートだけを歩いても宝物庫には行けます。秘宝が扉を開ける条件になっているわけではありません。秘宝の意味づけは第10弾で扱います


おわりに

今回、実装したもの

今回は、迷いの回廊をまるごと組み上げました。実装したものを振り返ります。

迷いの回廊を、まるごと組み上げました
  • Corner_L区画の自作。

  • 霧のFadeTo化と、Fogアクションの型安全な削除。

  • 15区画からなるループ回廊。

  • CharacterControllerを安全にワープさせるTeleportTo+SyncTransforms。

  • Cinemachine 3のOnTargetObjectWarpedによる、カメラのシームレスなワープ追従。

  • 分岐の見分けを引き直す記憶ギミック。

  • 無進捗を救う導きの精霊。

  • フェーズの自動前進。

  • correctSide連動と、開けっ放しのランドマーク化。

  • 秘宝カウント(正解の宝箱を開けると増える)。

📝 いまはまだ、秘宝も迷路の攻略記録も保存されません。第10弾では、PlayerPrefsのビットフラグを使って「集めた秘宝が次の周回に残る」ようにする予定です。今回の秘宝カウントは、その布石です。


第9弾予告

第9弾予告

次回・第9弾は『番人ゴーレム — 巡回AIと、隠れて逃げるアクション』です。AI NavigationでNavMeshをベイクし、ゴーレムに通路を巡回させます。第5弾で予告した「壁越しに調べられてしまう問題」を、遮蔽レイキャストで解決します。さらに、第4弾のランタンが「点ければ明るいが見つかりやすく、消せば暗いが隠密」というステルス要素に昇華します(LanternController.IsOnの布石回収です)。もちろん、明るい冒険のトーンのままお届けします。

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

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