見出し画像

【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弾:『番人ゴーレムを実装する(NavMesh巡回・視界判定・ランタンステルス)』

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

  11. 🆕 【今回の記事】第11弾(前編):『遺跡に演出を仕込む(コルーチン演出・強制歩行・パーティクル・仕掛けの扉)』
    📅 [執筆予定]
    第11弾(後編):『推理ボードとマルチエンディング』
    ※ 第11弾は「前編」「後編」に分けます

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

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

第11弾は「演出と結末」という1本の予定でしたが、書き出してみると第10弾と同じく1本に収まりませんでした(この「前編」だけで10万文字近くあります)。前編が「演出」、後編が「結末」です。弾の番号は変わりません。

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

前回までで、この遺跡はひととおり「遊べる」ようになりました。歩けて、調べられて、宝箱の真贋を見抜けて、番人から隠れられて、音も鳴ります。タイトルから始まり、エンディングで終わります。

それなのに、通して遊んでみると、どこかそっけないのです。

  • ゲームが始まると、いきなり操作できる。どこへ行けばいいのか、誰も教えてくれない

  • 宝箱を見抜いても、フタが「パッ」と消えて、光る玉が「パッ」と出るだけ

  • 第9弾で作った視線スイッチは、灯っても何も起きない(コードに📢を残したままです)

  • 第8弾で「凝った演出は第11弾で」と約束した導きの精霊は、光る玉がまっすぐ飛ぶだけ

足りないのは演出です。今回は、この遺跡に「間(ま)」と「見せ場」を足していきます。

■ 道具は、もう持っています
「演出」と聞くと、特別な機能が要りそうに思えます。ですが今回使う道具の中心は、第4弾からずっと使ってきたコルーチンです。

第4弾では、霧の濃さを時間をかけて変えました。第7弾では、結果表示をふわっと出して消しました。第10弾前編では、画面を暗転させてシーンをつなぎました。

『ある値を、時間をかけて、AからBへ動かす』
やっていたことは、3つとも同じです。

今回はこの型を、画面の明るさ、アバターのまぶた、音量、プレイヤーの位置、カメラの向き、宝箱のフタ、扉へと、次々に当てはめていきます。新しく覚える機能は、パーティクルシステムと、Cinemachineの軸の操作くらいです。


今回の到達点

  • 石碑を読んでいる間や演出の最中は、番人がきちんと止まる

  • ゲームを始めると、アバターが自動で数歩あるき、カメラが行き先を示し、画面がきらめいてから操作が始まる(導入演出)

  • 導入演出は初回だけ流れる。2周目からは、すぐ動ける

  • アバターがまばたきをする

  • 大事な場面の前に、音がすっと引く「静けさ」を作れる

  • 天井からしずくが落ち、通路を塵が漂う

  • 導きの精霊が、ふわふわ揺れながら光の尾を引いて飛ぶ

  • 通路を巡回の列が横切る。柱の陰でやり過ごせば先へ進める。見つかれば手前へ戻される

  • 宝箱を見抜くと、フタがゆっくり開き、秘宝が現れる

  • 視線スイッチを灯すと、宝物庫の奥で出口の扉が開く(第9弾の📢を回収)

👩‍💻 こんな方に向けた内容です
・コルーチンは書けるけれど、演出にどう使えばいいか分からない方
・Cinemachine 3系のカメラを、スクリプトから動かしたい方
・VRoid(VRM)のアバターに、まばたきをさせたい方
・パーティクルシステムを、初めて触る方

🛠️ 動作検証環境
・macOS Tahoe 26.5.1
・Unity 6.5(6000.5.5f1)+URP 17.5.0
・Input System 1.20.0/Cinemachine 3.1.7/UniVRM 0.131.2(VRM0.x)
・ProBuilder 6.1.2/Unity Localization 1.5.12/AI Navigation 2.0.14
・エディタは日本語化しています

📝 Unity 6.6について
2026年9月にUnity 6.6が正式にリリースされ、それに伴って6.5はサポート終了になりました。第2弾で「6.5のサポートは、6.6が出るまで」と書いたとおりの展開です。この連載は、6.5のまま最後まで進めます。 途中でエンジンを上げると、これまでの10本と画面や手順がズレてしまい、読み返したときに混乱するからです。これから新しく始める方は、長く使える6.3 LTSか、最新の6.6を選んでください。6.6で追試される方への注意は、第2章の📝に1つ書きました。


本記事の構成

第2章と第3章で部品を作り、第4章でそれを並べて最初の見せ場にします。第5章は息抜き、第6章と第7章が実践編です。


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

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

  • 新しいスクリプトは、これまでどおり全体を載せます

  • 改修のうち、手を入れる場所が何か所にもまたがる5本(GolemController・PlayerLocomotion・TreasureChest・ChestPairController・GazeSwitch)は、改修後の全体を載せます。追加・変更した行には // 💡 第11弾で… のコメントを付けました。丸ごと貼り替えて構いません

  • 1〜数行で済む3本(ScreenFader・AudioManager・CameraModeSwitcher)は、前後の行と一緒に差分で示します

📝 連載の途中でコードに独自の手を加えている方は、丸ごと貼り替えると、その変更が消えてしまいます。その場合は、💡 第11弾 で検索して、該当の行だけを写してください。

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


第2章 番人が止まらない! → 本当に止める

不具合の確認

演出を作り始める前に、1つ、直しておきたい不具合があります。実は第9弾からずっと、この遺跡に潜んでいたものです。

■ まず、困ってみる
ゲーム開始「▶」で、次のことを試してみてください。

  1. 分岐①と②を正しく抜けて、3本目の直線の通路(番人の巡回路)まで進む

  2. ランタンは点けたままにしておく

  3. 通路の入口にある「掟の石碑」に近づき、V で一人称にして、E で読む

  4. 刻文のパネルが開いたまま、じっと待つ

番人が向こうの端から戻ってくるタイミングだと、こうなります。

パネルを読んでいる最中に、画面上部のゲージが溜まり、頭上に「!」が出て、番人が一直線に歩いてきて——操作できないまま、捕まる。

パネルが開いている間、こちらは一歩も動けません。第6弾で作った UiGate が、移動もランタンも視点の切り替えも止めているからです。ところが番人だけは、止まっていなかったのです。


なぜ、こうなっていたのか

第6弾で UiGate を作ったとき、番人はまだいませんでした。第9弾で番人を作ったとき、「止まる条件」として入れたのは、第8弾の MazeDirector.IsReturning(霧に包まれて戻されている最中)だけでした。UIを開いている間のことは、考えていなかったのです。

石碑と番人の位置も、間が悪すぎました。掟の石碑はZ=37、番人の巡回の端(GW1)はZ=38。1メートルしか離れていません。

これから作る演出でも、同じことが起きます。宝箱がゆっくり開いている最中に、背後から捕まったら台無しです。先に「止まるべきときは、みんな止まる」仕組みを作っておきましょう。


演出用の旗を作る

止める理由は、2種類あります。

「UiGate をそのまま演出にも使えばいいのでは?」と思うかもしれません。私も最初はそうしようとしました。ですが、第10弾後編で足音を付けたとき、PlayerAudio に「UiGate が開いている間は、足音を鳴らさない」という1行を入れています。第4章で、演出としてアバターを自動で歩かせます。そのとき UiGate を立てていると、歩いているのに足音が鳴らないことになります。

UIを開いている間と、演出の最中は、止めたいものが少し違う。だから旗を分けます。

形は UiGate とまったく同じです。新しいスクリプトを作ります。

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

// 「いま演出の最中かどうか」を、ゲーム全体に知らせる旗。
// 第6弾の UiGate(UIが開いているか)の兄弟分です。
// static クラスなので、どのオブジェクトにもアタッチしません。
public static class StageGate
{
    // 演出の最中は true。演出のスクリプトが立てて、終わったら下ろす
    public static bool IsPlaying;

    // ゲームの再生を始めるたびに、必ず「下りた状態」から始める(下の📝を参照)
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void ResetOnPlay()
    {
        IsPlaying = false;
    }
}

Assets/Scripts にStageGate.csを作成。

[RuntimeInitializeOnLoadMethod] から下のかたまりは、Unity 6.6で追試する方のための保険です。 Unityには「再生ボタンを押すたびに、スクリプトの状態をまっさらに戻す」処理があり、6.5ではこれが最初から有効です。だから static の変数は、再生のたびに初期値へ戻ります。ところが、この処理を省いて再生の開始を速くする設定があり、公式マニュアルは、その場合は、staticの変数が再生をまたいで値を保ったままになるので、自分のコードで戻す必要がある、と説明しています。そして6.6では、新しく作るプロジェクトで、この速い設定が最初から選ばれるようになったと案内されています(私は6.5なので未検証です)。

もし値が残る設定で、演出の途中に再生を止めたらどうなるか。旗が立ったまま次の再生が始まり、番人が最初から最後まで動かない、という謎の不具合になります。このかたまりは、それを防ぎます。[RuntimeInitializeOnLoadMethod] は、「ゲームの開始時に、このメソッドを自動で1回呼んでください」という目印です。6.5のままの方には、あってもなくても同じ動きです。

このスクリプトは、どこにもアタッチしません。 static class は MonoBehaviour ではないので、そもそもオブジェクトに付けられません。誰も作らなくても、StageGate.IsPlaying と書けばどこからでも読み書きできます。第6弾の UiGate と同じです。

💡 第6弾の UiGate にも、同じ理屈が当てはまります。6.6の方は、UiGate にも同じかたまり(中身は IsOpen = false;)を足しておくと安心です。その場合は、UiGate.cs の先頭に using UnityEngine; も必要です。

■ 関所でも、旗を下ろす
第10弾前編で、ScreenFader に「関所」を作りました。シーンを切り替える直前に、止まった時間、止めた音、開いたままのUIの旗、カーソルを、まとめて正常に戻す場所です。コードのコメントに、「『タイトルに戻ったら全部止まってる』という定番バグの構造的な予防です」と書いた場所です。

新しい旗も、ここで下ろします。ScreenFader.cs を開いて、後片付けをしている場所を探してください。

FadeAndLoadRoutine の中の、「後片付け3点セット」と書いたところ。UiGate.IsOpen = false; の行の下に、2行を足します。

Time.timeScale = 1f;          // 時間を通常に戻す
AudioListener.pause = false;  // 音の一時停止を解除
UiGate.IsOpen = false;        // UIゲートを開ける(第6弾のstaticフラグ)
StageGate.IsPlaying = false;  // 💡 第11弾で追加:演出の旗も、必ず下ろす
AudioListener.volume = 1f;    // 💡 第11弾で追加:全体の音量も、必ず元へ(第3章で使います)

「3点セット」が、これで5点になりました。

2行目の音量は、次の章で使います。いまは何も変わりませんが、ScreenFader を二度開かなくて済むよう、ここで一緒に入れておきます。

📝 ScreenFader は、FaderCanvas というPrefabに付いています(BootとMainの両方に置いたもの)。スクリプトのファイルを書き換えれば、両方に反映されます。Prefabを開き直す必要はありません。


目玉つまずき:Updateを抜けても、番人は止まらない

さて、本題の番人です。第9弾の GolemController には、すでに「止まる」処理が1つ入っています。Update() の最初の、この3行です。

// テレポート(霧で戻される)中は、番人は一切追いかけません。
if (MazeDirector.Instance != null && MazeDirector.Instance.IsReturning)
    return;

「ここに UiGate.IsOpen と StageGate.IsPlaying も足せば、終わりでは?」——私は最初、そう考えました。ですが、コードをよく読み返すと、これでは止まらないことが分かります。

  1. 歩かせているのは、Update() ではない。 番人を歩かせているのは、第9弾で付けた NavMesh Agent です。スクリプトは agent.SetDestination(...) で「あそこへ行け」と目的地を伝えているだけで、実際に足を動かしているのはAgent自身です。Update() を return で抜けると、新しい命令は出なくなります。でも、最後に出した命令は生きたままです。追跡中なら、最後に伝えたプレイヤーの位置へ。巡回中なら、次の巡回点へ。Agentは黙々と歩き続けます。

  2. 捕まえているのも、Update() ではない。 捕獲は OnTriggerEnter で書きました。これはUnityの物理エンジンが、「当たり判定に何かが入った」瞬間に呼ぶメソッドです。Update() とは別の入口なので、Update() の頭で return しても、何の影響もありません。

つまりこの return は、「考える」のを止めているだけで、「歩く」と「捕まえる」は動いたままなのです。

📝 霧で戻す処理では、これで問題が出ませんでした。戻している1秒ちょっとの間に、プレイヤーのほうが遠くへワープしてしまうので、番人が歩き続けていても追いつけなかったからです。石碑を読む間は、そうはいきません。こちらは、その場から一歩も動けないのです。

止めるには、次の3つが全部要ります。

■ NavMesh Agent を止める、正しい方法
Agentには isStopped という項目があります。Unityの公式リファレンスの説明は、こうです。

  • true にすると、現在の経路の途中で移動を止める

  • 止めたあとで false にすると、同じ経路の続きから移動を再開する

これは今回の用途にぴったりです。目的地を忘れさせる(ResetPath)のではなく、一時停止ボタンを押すだけなので、パネルを閉じたら、番人は何事もなかったように巡回の続きを歩き出します。

1つだけ注意があります。isStopped = true にしても、Agentは設定された減速で止まろうとするので、少しだけ惰性で滑ります。ぴたりと止めたいので、速度も0にします。


GolemController を改修する

GolemController.cs の、改修後の全体です。変わったのは4か所だけです。

// ==========================================================
// 📄 スクリプト:GolemController.cs(第11弾前編・改修後・全体)
// ==========================================================
using UnityEngine;
using UnityEngine.AI;
using TMPro; // 頭上マーク(第6章)で使うので、先に using を用意しておきます

public class GolemController : MonoBehaviour
{
    public enum GuardState { Patrol, Alert, Chase, Lost }

    [Header("巡回設定")]
    [SerializeField] private Transform[] waypoints;
    [SerializeField] private float waitTime = 1.5f;

    [Header("目のマテリアル(放出オン済み)")]
    [SerializeField] private Material eyePatrol;
    [SerializeField] private Material eyeAlert;
    [SerializeField] private Material eyeChase;
    [SerializeField] private Renderer eyeRenderer;

    [Header("視界設定")]
    [SerializeField] private Transform playerTarget;
    [SerializeField] private float viewAngle = 90f;
    [SerializeField] private float eyeHeight = 2.2f;

    [Header("ランタン連動ステルス")]
    [SerializeField] private LanternController playerLantern; // 第5弾のIsOnを回収!
    [SerializeField] private float lanternOnDistance = 12f;   // 点灯中は遠くまで見える
    [SerializeField] private float lanternOffDistance = 5f;   // 消灯中は近くしか見えない
    private float viewDistance = 6f; // 実際に使う視界距離(ランタンで毎フレーム更新)

    [Header("検知ゲージ")]
    [SerializeField] private float fillTime = 1.2f;  // 視認中に満タンまで
    [SerializeField] private float drainTime = 2.0f; // 非視認で0まで
    private float detectionLevel = 0f;               // 0〜1

    [Header("追跡・見失い")]
    [SerializeField] private float chaseSpeed = 3.5f;
    [SerializeField] private float lostGraceTime = 1.5f; // 見失ってから諦めるまで
    [SerializeField] private float searchWait = 3f;      // 最後の地点で探す秒数
    [SerializeField] private float setDestInterval = 0.3f; // 追跡中の再指定間隔

    [Header("捕獲")]
    [SerializeField] private Transform golemReturnAnchor; // 戻される位置

    // ⏳→✅ 予約席(b)を有効化
    [Header("警戒の見える化")]
    [SerializeField] private DetectionGaugeUI gauge;  // GaugeBackを割当
    [SerializeField] private TextMeshPro alertMark;   // AlertMarkを割当

    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip alertClip;   // 警戒(?)
    [SerializeField] private AudioClip chaseClip;   // 発見(!)
    [SerializeField] private AudioClip lostClip;    // 見失い
    [SerializeField] private AudioClip captureClip; // 捕獲

    private LayerMask obstructionMask;
    private NavMeshAgent agent;
    private int currentIndex = 0;
    private float waitTimer = 0f;
    private bool isWaiting = false;
    private GuardState state = GuardState.Patrol;

    private Vector3 lastKnownPos;      // 最後に見た位置
    private float lostTimer = 0f;      // 見失い計測
    private float searchTimer = 0f;    // 探索計測
    private float setDestTimer = 0f;   // 再指定計測
    private float cooldownTimer = 0f;  // 捕獲後、追い直さないクールダウン

    // 💡 第11弾で追加:止まるべきときに「本当に」止まるための変数
    private bool frozen;      // いま凍結中か
    private bool wasStopped;  // 凍結する直前、Agentは止まっていたか(解除のとき元に戻すため)

    void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        obstructionMask = LayerMask.GetMask("Default");
    }

    void Start()
    {
        ChangeState(GuardState.Patrol);
        if (waypoints != null && waypoints.Length > 0)
            agent.SetDestination(waypoints[currentIndex].position);
    }

    void Update()
    {
        // 💡 第11弾で変更:UIを開いている間・演出の最中・霧で戻している最中は、
        //    「考える」のをやめるだけでなく、「歩く」のも止めます(理由は本文)。
        if (HandleFreeze()) return;

        // 捕獲直後のクールダウン中はゲージを溜めない
        if (cooldownTimer > 0f) cooldownTimer -= Time.deltaTime;

        // ランタンで視界距離を毎フレーム更新(点ければ遠く、消せば近く)
        viewDistance = (playerLantern != null && playerLantern.IsOn)
                       ? lanternOnDistance : lanternOffDistance;

        bool seen = (cooldownTimer <= 0f) && CanSeePlayer();

        switch (state)
        {
            case GuardState.Patrol: PatrolUpdate(seen); break;
            case GuardState.Alert:  AlertUpdate(seen);  break;
            case GuardState.Chase:  ChaseUpdate(seen);  break;
            case GuardState.Lost:   LostUpdate(seen);   break;
        }        
    }

    void LateUpdate()
    {
        // ゲージを更新
        if (gauge != null) gauge.SetLevel(detectionLevel);

        // 頭上マーク:警戒中は「?」、追跡中は「!」、それ以外は非表示
        if (alertMark != null)
        {
            switch (state)
            {
                case GuardState.Alert:
                    alertMark.gameObject.SetActive(true);
                    alertMark.text = "?";
                    break;
                case GuardState.Chase:
                    alertMark.gameObject.SetActive(true);
                    alertMark.text = "!";
                    break;
                default: // Patrol / Lost帰投
                    alertMark.gameObject.SetActive(false);
                    break;
            }
        }
    }

    // --- 巡回 ---
    private void PatrolUpdate(bool seen)
    {
        if (seen) { ChangeState(GuardState.Alert); return; }

        if (waypoints == null || waypoints.Length == 0) return;
        if (isWaiting)
        {
            waitTimer -= Time.deltaTime;
            if (waitTimer <= 0f)
            {
                isWaiting = false;
                currentIndex = (currentIndex + 1) % waypoints.Length;
                agent.SetDestination(waypoints[currentIndex].position);
            }
            return;
        }
        if (ReachedDestination())
        {
            isWaiting = true;
            waitTimer = waitTime;
        }
    }

    // --- 警戒:その場で止まり、プレイヤー方向へゆっくり旋回。ゲージを溜める ---
    private void AlertUpdate(bool seen)
    {
        agent.isStopped = true;

        if (seen)
        {
            lastKnownPos = playerTarget.position;
            FaceTarget(lastKnownPos);
            detectionLevel += Time.deltaTime / fillTime;
            if (detectionLevel >= 1f)
            {
                detectionLevel = 1f;
                agent.isStopped = false;
                ChangeState(GuardState.Chase); // 満タン→追跡
            }
        }
        else
        {
            // 見えなくなったらゲージが減る。0になったら巡回へ戻る。
            detectionLevel -= Time.deltaTime / drainTime;
            if (detectionLevel <= 0f)
            {
                detectionLevel = 0f;
                agent.isStopped = false;
                ReturnToPatrol();
            }
        }
    }

    // --- 追跡:0.3秒間隔でSetDestination。見失ったらLostへ ---
    private void ChaseUpdate(bool seen)
    {
        agent.speed = chaseSpeed;
        detectionLevel = 1f;

        if (seen)
        {
            lastKnownPos = playerTarget.position;
            lostTimer = 0f;
            setDestTimer -= Time.deltaTime;
            if (setDestTimer <= 0f)
            {
                setDestTimer = setDestInterval; // 毎フレームではなく間隔をあけて呼ぶ
                agent.SetDestination(lastKnownPos);
            }
        }
        else
        {
            // 視認が途切れた:猶予時間を数え、超えたら見失いへ
            lostTimer += Time.deltaTime;
            if (lostTimer >= lostGraceTime)
            {
                ChangeState(GuardState.Lost);
                agent.SetDestination(lastKnownPos);
            }
        }
    }

    // --- 見失い:最後の地点へ→到達→探す→巡回へ帰投 ---
    private void LostUpdate(bool seen)
    {
        if (seen)
        {
            // 探索中にまた見えたら追跡へ復帰
            detectionLevel = 1f;
            ChangeState(GuardState.Chase);
            return;
        }
        agent.speed = 2f; // 通常速度に戻す

        if (ReachedDestination())
        {
            // 最後の地点に着いた。その場で少し探す(ゆっくり旋回)。
            searchTimer += Time.deltaTime;
            transform.Rotate(0, 60f * Time.deltaTime, 0);
            if (searchTimer >= searchWait)
            {
                searchTimer = 0f;
                ReturnToPatrol();
            }
        }
    }

    // --- 巡回に戻る:最寄りのウェイポイントへ ---
    private void ReturnToPatrol()
    {
        detectionLevel = 0f;
        currentIndex = NearestWaypointIndex();
        ChangeState(GuardState.Patrol);
        agent.isStopped = false;
        isWaiting = false;
        if (waypoints != null && waypoints.Length > 0)
            agent.SetDestination(waypoints[currentIndex].position);
    }

    private int NearestWaypointIndex()
    {
        int best = 0;
        float bestSq = float.MaxValue;
        for (int i = 0; i < waypoints.Length; i++)
        {
            float sq = (waypoints[i].position - transform.position).sqrMagnitude;
            if (sq < bestSq) { bestSq = sq; best = i; }
        }
        return best;
    }

    // --- 状態を変える瞬間に目の色を切り替え ---
    private void ChangeState(GuardState next)
    {
        state = next;
        ApplyEyeMaterial(next);
        Debug.Log($"番人の状態:{next}"); // ← 動作確認用(第6章でHUDに置き換えます)
        // 📢 第10弾でここに警戒音・発見音を差し込みます
        // 📢→🔊 第10弾で回収。状態が「変わった瞬間」に、番人の位置で1回だけ鳴らす。
        // switch式で「次の状態→鳴らす音」を引く(Patrolへ戻るときは鳴らさない)
        AudioClip clip = next switch
        {
            GuardState.Alert => alertClip,
            GuardState.Chase => chaseClip,
            GuardState.Lost  => lostClip,
            _                => null,
        };
        if (clip != null)
            AudioManager.Instance?.PlaySeAt(clip, transform.position);
    }

    private bool ReachedDestination()
    {
        return !agent.pathPending &&
               agent.remainingDistance <= agent.stoppingDistance &&
               (!agent.hasPath || agent.velocity.sqrMagnitude == 0f);
    }

    private void FaceTarget(Vector3 point)
    {
        Vector3 dir = point - transform.position;
        dir.y = 0;
        if (dir.sqrMagnitude < 0.001f) return;
        Quaternion look = Quaternion.LookRotation(dir);
        transform.rotation = Quaternion.Slerp(transform.rotation, look, 2f * Time.deltaTime);
    }

    public bool CanSeePlayer()
    {
        if (playerTarget == null) return false;
        Vector3 eye = transform.position + Vector3.up * eyeHeight;
        Vector3 targetPoint = playerTarget.position + Vector3.up * 1.2f;
        Vector3 toPlayer = targetPoint - eye;
        if (toPlayer.sqrMagnitude > viewDistance * viewDistance) return false;
        float angle = Vector3.Angle(transform.forward, toPlayer);
        if (angle > viewAngle * 0.5f) return false;
        if (Physics.Linecast(eye, targetPoint, out RaycastHit hit,
                             obstructionMask, QueryTriggerInteraction.Ignore))
            return false;
        return true;
    }

    public void ApplyEyeMaterial(GuardState s)
    {
        if (eyeRenderer == null) return;
        switch (s)
        {
            case GuardState.Patrol: eyeRenderer.sharedMaterial = eyePatrol; break;
            case GuardState.Alert:  eyeRenderer.sharedMaterial = eyeAlert;  break;
            case GuardState.Chase:  eyeRenderer.sharedMaterial = eyeChase;  break;
            case GuardState.Lost:   eyeRenderer.sharedMaterial = eyeAlert;  break;
        }
    }

    // --- 捕獲トリガー:プレイヤーに触れたら霧で戻す ---
    private void OnTriggerEnter(Collider other)
    {
        // 💡 第11弾で変更:凍結中は捕まえない(霧で戻している最中も、この中に含まれます)
        if (ShouldFreeze()) return;
        // CharacterControllerを持つ=プレイヤー(連載の作法)
        if (other.GetComponent<CharacterController>() == null) return;

        // 「見つかった」演出→霧で戻す
        AudioManager.Instance?.PlaySe(captureClip); // 🔊 追加:自分に起きた出来事なので2D
        JudgeFeedbackUI.Instance.ShowMessage("見つかった……! 霧に包まれて戻される");
        MazeDirector.Instance.ReturnPlayer(golemReturnAnchor);
        ResetToPatrol();
    }

    // --- 捕獲後:ゲージ0・巡回・最寄りWPへ。しばらく追い直さない ---
    private void ResetToPatrol()
    {
        detectionLevel = 0f;
        cooldownTimer = 2f; // 2秒は再検知しない
        ReturnToPatrol();
    }

    // ==== 💡 第11弾で追加:止まるべきときは、本当に止まる ====

    // いま、番人は止まっているべきか
    private bool ShouldFreeze()
    {
        if (UiGate.IsOpen) return true;        // 石碑・手記・名簿・ポーズを開いている
        if (StageGate.IsPlaying) return true;  // 演出の最中
        if (MazeDirector.Instance != null && MazeDirector.Instance.IsReturning) return true; // 霧で戻している最中
        return false;
    }

    // 凍結の「入り」と「明け」を処理する。凍結中なら true を返す
    private bool HandleFreeze()
    {
        bool freeze = ShouldFreeze();
        if (freeze == frozen) return frozen; // 前のフレームと同じなら、何もしない

        frozen = freeze;
        if (freeze)
        {
            wasStopped = agent.isStopped;    // 直前の状態を覚えておく(警戒中は、もともと止まっている)
            agent.isStopped = true;          // 経路はそのまま、一時停止
            agent.velocity = Vector3.zero;   // 惰性で滑らないように
        }
        else
        {
            agent.isStopped = wasStopped;    // 覚えておいた状態に戻す → 同じ経路の続きから歩き出す
        }
        return frozen;
    }

    public float DetectionLevel => detectionLevel;   // 第6章のゲージが読む
    public GuardState State => state;                // 第6章の頭上マークが読む

    [ContextMenu("次の地点へ")]
    private void DebugGoNext()
    {
        if (waypoints == null || waypoints.Length == 0) return;
        currentIndex = (currentIndex + 1) % waypoints.Length;
        agent.SetDestination(waypoints[currentIndex].position);
        isWaiting = false;
    }

    // 状態を日本語ラベルで返す(HUD表示用)
    public string StateLabel => state switch
    {
        GuardState.Patrol => "巡回",
        GuardState.Alert  => "警戒",
        GuardState.Chase  => "追跡",
        GuardState.Lost   => "見失い",
        _ => "―"
    };
}

コード改修における3つの要点です。

wasStopped は、なぜ要るのか。 番人は「警戒」の状態のとき、その場に立ち止まってこちらを見ます(AlertUpdate の最初の agent.isStopped = true;)。つまり、凍結する前からAgentが止まっていることがあります。解除のときに無条件で isStopped = false にすると、立ち止まっているはずの番人が歩き出してしまいます。だから、凍結の直前の状態を覚えておいて、その状態に戻します。

HandleFreeze() は、変化した瞬間にだけ働きます。 「前のフレームと同じなら何もしない」の1行があるので、Agentに命令を出すのは、凍結に入った瞬間と、明けた瞬間の2回だけです。毎フレームAgentをつつくことには、なりません。

もとの「IsReturning なら return」は、ShouldFreeze() の中へ引っ越しました。 止まる理由が3つに増えたので、1か所にまとめています。霧で戻している間も、これからは「考える」だけでなく「歩く」も止まります。

📝 丸ごと貼り替えても、インスペクターの割り当ては消えません。 巡回点、目のマテリアル、効果音など、第9弾と第10弾後編で割り当てたものは、そのまま残ります。Unityは、割り当てを「変数の名前」で覚えているからです。今回は、もとからある変数の名前を1つも変えていません。この記事のほかの全体コードも、同じです。

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

  • 冒頭の手順をもう一度試す。石碑を読んでいる間、番人がその場で止まる。 ゲージも増えない

  • パネルを閉じると、番人が同じ巡回の続きを歩き出す(入口まで戻ったりしない)

  • 手記と名簿でも同じ

  • Esc でポーズ → 再開しても、番人の動きがおかしくならない

  • 分岐でわざと間違えて、霧で戻される。これまでどおり、その間は番人が止まっている

  • 番人にわざと捕まってみる。これまでどおり捕まる(止めすぎていないことの確認)


第3章 値をなめらかに動かす(画面のきらめき・まばたき・静けさ)

主旨

この章では、演出の部品を3つ作ります。画面が一瞬ふわっと光る「きらめき」、アバターの「まばたき」、そして音がすっと引く「静けさ」です。見た目はバラバラですが、中身は3つとも同じです。それを確かめるのが、この章のねらいです。

■ おさらい:連載で3回書いた「あの形」
これまでの連載で、次の3つを作りました。

コードの骨格は、3つとも次の形でした。

// 「値を、時間をかけて from から to へ動かす」型(第4弾の骨格)
private IEnumerator Fade(float from, float to, float duration)
{
    float time = 0f;
    while (time < duration)
    {
        time += Time.deltaTime;                 // ① 経過時間を足す
        float t = time / duration;              // ② 0 → 1 の進み具合を求める
        float value = Mathf.Lerp(from, to, t);  // ③ 進み具合から、中間の値を出す
        // ★ここで value を「何か」に入れる(霧の濃さ、透明度、音量……)
        yield return null;                      // ④ 次のフレームまで待つ
    }
    // ★最後に to をきっちり入れる(誤差をなくすため)
}

違うのは、★の2か所に何を入れるかだけです。今回は、ここに「画面を覆う白い板の透明度」「まぶたの閉じ具合」「全体の音量」を入れます。

最初の「きらめき」は、UIの作り方も含めて丁寧に進めます。残りの2つは、同じ形の差分として、短く済ませます。


3-1. 画面のきらめき

画面全体を覆う白い板を用意して、その透明度を「0 → 0.85 → 0」と動かします。仕組みは、第10弾前編の暗転とまったく同じです。違いは、色が白いことと、すぐ戻ることだけです。

■ UIを作る:FlashOverlay

① 白い板を作る

  1. ヒエラルキーで、Canvas を右クリック ▸ UI (Canvas) ▸ 画像(Image)

  2. できたオブジェクトの名前を FlashOverlay に変更

📝 FaderCanvas(暗転用。第10弾前編)の中ではなく、ふだん使っているほうの Canvas の中に作ります。FaderCanvas は「何よりも手前」に描くための特別なキャンバスなので、暗転以外のものは入れません。

② 画面いっぱいに広げる

  1. FlashOverlay を選択し、インスペクターの Rect Transform の左上にある、四角いアイコン(アンカープリセット)をクリック

  2. プリセットの一覧が開いたら、Optionキー(WindowsはAltキー)を押しながら、右下の stretch / stretch(縦横とも引き伸ばし)をクリック

  3. Rect Transform の 左・上・右・下 が、すべて 0 になっていることを確認(なっていなければ、0を入力)

💡 Option(Alt)を押しながら選ぶと、アンカーだけでなく、位置と大きさも一緒に合わせてくれます。第10弾前編の FadeImage と同じ操作です。

③ 色と、クリックの扱いを決める
インスペクターの 画像(Image) コンポーネントで設定します。

⚠️ レイキャストターゲットは、必ずオフにしてください。 オンのままだと、この透明な板が画面全体でマウスのクリックを吸い取ってしまい、下にあるボタン(ポーズメニューなど)が押せなくなります。透明でも、クリックは受け止めてしまうのです。第9弾の検知ゲージでも、同じ理由でオフにしました。

色のA(不透明度)は255のままです。透明度は、次の Canvas Group のほうで動かします。

④ Canvas Group を付ける

  1. FlashOverlay を選択したまま、インスペクターの一番下の コンポーネントを追加 をクリック

  2. 検索欄に Canvas Group と入力し、Canvas Group(キャンバスグループ) を選ぶ

  3. 次のように設定

アルファを0にした瞬間、ゲームビューから白い板が消えます。消えていなければ、ふだんの画面が真っ白のままになるので、すぐ気づけます。

⑤ 重なりの順番を直す
ヒエラルキーで、Canvas の子の並びを見てください。UIは、下にあるものほど手前に描かれます。いま作った FlashOverlay は一番下、つまり一番手前にいます。このままだと、きらめきの途中でポーズを開いたとき、白い板がポーズメニューの上にかぶさったまま止まってしまいます。

FlashOverlay をドラッグして、PausePanel よりも上(=奥)へ移動します(PausePanel のすぐ上に置けば十分です)。

📝 第10弾後編で作ったミニマップ(MinimapFrame)が、PausePanel より下にある方は、きらめきの間も、ミニマップだけが光に覆われずに残ります。気になる場合は、MinimapFrame も FlashOverlay より上へ移してください。並びは、上から「ふだんのUI → FlashOverlay → PausePanel」の順になっていれば大丈夫です。

■ スクリプト:ScreenFlash.cs
Assets/Scripts に作成します。

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

// 画面を一瞬ふわっと光らせる係。FlashOverlay(白い板)にアタッチします。
// 仕組みは ScreenFader(第10弾前編)と同じ「Canvas Group のアルファを動かす」型。
public class ScreenFlash : MonoBehaviour
{
    // Mainシーンの中だけで使う窓口(JudgeFeedbackUI と同じ、シーン内シングルトン)
    public static ScreenFlash Instance { get; private set; }

    [SerializeField] private CanvasGroup canvasGroup;

    [Header("光り方")]
    [SerializeField] private float peakAlpha = 0.85f;  // いちばん光った瞬間の不透明度(1だと真っ白)
    [SerializeField] private float riseTime  = 0.06f;  // 光るまでの時間(秒)。短いほど鋭い
    [SerializeField] private float fallTime  = 0.5f;   // 消えるまでの時間(秒)。長いほど余韻が残る

    private Coroutine routine;

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

        canvasGroup.alpha = 0f;   // 念のため、透明から始める
    }

    // 光らせる(投げっぱなしでよいとき用)
    public void Flash()
    {
        if (routine != null) StopCoroutine(routine);   // 連続で呼ばれたら、前のを止めてやり直す
        routine = StartCoroutine(FlashRoutine());
    }

    // 光らせて、消えるまで待つ(演出の中で「光り終わってから次へ」と書きたいとき用)
    public IEnumerator FlashRoutine()
    {
        yield return Fade(canvasGroup.alpha, peakAlpha, riseTime);  // ぱっと光る
        yield return Fade(peakAlpha, 0f, fallTime);                 // ゆっくり消える
        routine = null;
    }

    // 「値を、時間をかけて from から to へ動かす」型。今回入れる値は、アルファ
    private IEnumerator Fade(float from, float to, float duration)
    {
        float time = 0f;
        while (time < duration)
        {
            time += Time.deltaTime;                       // 経過時間を足す
            float t = time / duration;                    // 0 → 1 の進み具合
            canvasGroup.alpha = Mathf.Lerp(from, to, t);  // ★値を入れる先:アルファ
            yield return null;
        }
        canvasGroup.alpha = to; // 誤差をなくすため最後にきっちり
    }

    // インスペクターの右クリックメニューから試せるようにする(再生中のみ)
    [ContextMenu("きらめきテスト")]
    private void TestFlash() => Flash();
}

■ アタッチと割り当て

  1. ヒエラルキーの FlashOverlay を選択

  2. ScreenFlash スクリプトをドラッグしてアタッチ

  3. インスペクターの Canvas Group 欄に、FlashOverlay 自身をヒエラルキーからドラッグ

⚠️ FlashOverlay は、アクティブのままにしておきます。第6弾の LorePanel のように SetActive(false) で隠す型ではありません。非アクティブのオブジェクトではコルーチンを始められないからです。透明(アルファ0)にして置いておくだけにします。第7弾の JudgePanel と同じ型です。

■ 動作確認
ゲーム開始「▶」で確認します。このきらめきを実際に使うのは次の章なので、ここでは単体で試します。第8弾の「霧テスト」と同じ方法です。

  1. 再生したまま、ヒエラルキーで FlashOverlay を選択

  2. インスペクターの Screen Flash の見出し(コンポーネント名)を右クリック ▸ きらめきテスト(見出しの右端の ⋮ をクリックしても、同じメニューが出ます)

  • 画面が一瞬、白く光って、すっと戻る

  • 光ったあと、ポーズメニューのボタンがこれまでどおり押せる(板がクリックを吸っていない)

  • Peak Alpha を 1 にすると真っ白に、Fall Time を 1.5 にすると余韻が長くなる。好みの光り方を探してみてください


3-2. アバターのまばたき

次は、アバターにまばたきをさせます。いまのアバターは、目を見開いたまま一度も閉じません。気づいてしまうと、少し怖いです。

動かす値は「まぶたの閉じ具合」。0で開いていて、1で閉じています。0 → 1 → 0 と動かせば、まばたきです。

■ まぶたは、どこで動かすのか
VRoidで作ったモデルの顔には、「まぶたを閉じた形」「口を『あ』の形に開けた形」といった、表情の部品があらかじめ仕込まれています。これを BlendShape(ブレンドシェイプ) と呼びます。

ただ、BlendShapeの名前は、モデルを作ったソフトや版によってバラバラです。そこでVRMには、「このモデルの『まばたき』は、これとこれ」と対応づけてくれる仲介役がいます。それが VRM Blend Shape Proxy というコンポーネントです。スクリプトからは、「まばたきを0.7にして」と役割の名前で頼むだけで済みます。

ヒエラルキーで model_test を選択して、インスペクターを見てください。第1弾でVRMを取り込んだとき、UniVRMが自動で付けてくれた VRM Blend Shape Proxy があるはずです。

■ 目玉つまずき:古い記事の書き方は、いまは「非推奨」
ここに、落とし穴がありました。「VRM まばたき スクリプト」で検索すると、多くの記事が次の書き方を紹介しています

// ⚠️ 古い記事に多い書き方
proxy.ImmediatelySetValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.Blink), 1.0f);

動くことは動きます。ところが、UniVRMの公式ドキュメント(vrm.dev)を確認すると、現在の位置づけはこうなっていました。

公式の説明は、「そのフレームで必要な表情の値を全部集めてから、SetValues を1回だけ呼ぶ」です。

なぜ非推奨なのかというと、 公式は、左目(Blink_L)と右目(Blink_R)を ImmediatelySetValue で別々に閉じようとする例を挙げています。1回呼ぶたびに、顔全体の状態が「その1つだけ」で上書きされるため、後から呼んだ方だけが勝ち、片目しか閉じないのです。今回は両目用の Blink を1つ使うだけなので、この罠には当たりません。ですが、この先、まばたきしながら笑う、といったことをやりたくなったとき、古い書き方のままだと必ずここで詰まります。最初から推奨の書き方にしておきます。

📝 もう1つ。かなり古い記事のコードを貼ると、「CreateFromPreset なんて見当たりません」というエラーが出ることがあります。これはUniVRMの版が古い場合です。この連載の版(0.131.2)では問題ありません。

■ スクリプト:AvatarBlink.cs

// ==========================================================
// 📄 スクリプト:AvatarBlink.cs(新規)
// ==========================================================
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using VRM;   // UniVRM(VRM0.x)の部品を使う

// アバターにまばたきをさせる係。アバター(model_test)にアタッチします。
// 動かす値は「まぶたの閉じ具合」。0=開いている、1=閉じている。
public class AvatarBlink : MonoBehaviour
{
    [SerializeField] private VRMBlendShapeProxy proxy;   // 空のままでも、同じオブジェクトから自動で探します

    [Header("自動のまばたき")]
    [SerializeField] private bool  autoBlink   = true;
    [SerializeField] private float minInterval = 2.5f;   // 次のまばたきまでの、最短の秒数
    [SerializeField] private float maxInterval = 6.0f;   // 最長の秒数(この間でランダム)

    [Header("1回のまばたきの速さ(秒)")]
    [SerializeField] private float closeTime = 0.06f;    // 閉じる
    [SerializeField] private float holdTime  = 0.04f;    // 閉じたまま
    [SerializeField] private float openTime  = 0.12f;    // 開く(閉じるより少し遅いと自然)

    // SetValues に渡す「表情の一覧」。毎回作り直さず、1個を使い回す
    private readonly Dictionary<BlendShapeKey, float> values = new Dictionary<BlendShapeKey, float>();
    private BlendShapeKey blinkKey;
    private bool blinking;

    private void Awake()
    {
        if (proxy == null) proxy = GetComponent<VRMBlendShapeProxy>();

        blinkKey = BlendShapeKey.CreateFromPreset(BlendShapePreset.Blink);  // 「両目のまばたき」という役割の名札
        values[blinkKey] = 0f;
    }

    private void OnEnable()
    {
        if (autoBlink) StartCoroutine(AutoBlinkLoop());
    }

    // ランダムな間隔で、まばたきを繰り返す
    private IEnumerator AutoBlinkLoop()
    {
        while (true)
        {
            yield return new WaitForSeconds(Random.Range(minInterval, maxInterval));
            yield return BlinkOnce();
        }
    }

    // 1回まばたきする。演出から呼んでもよい
    public IEnumerator BlinkOnce()
    {
        if (blinking) yield break;   // まばたきの最中に、もう1回は始めない
        blinking = true;

        yield return Move(0f, 1f, closeTime);           // 閉じる
        yield return new WaitForSeconds(holdTime);      // 一瞬そのまま
        yield return Move(1f, 0f, openTime);            // 開く

        blinking = false;
    }

    // 「値を、時間をかけて from から to へ動かす」型。今回入れる値は、まぶたの閉じ具合
    private IEnumerator Move(float from, float to, float duration)
    {
        float time = 0f;
        while (time < duration)
        {
            time += Time.deltaTime;             // 経過時間を足す
            float t = time / duration;          // 0 → 1 の進み具合
            SetBlink(Mathf.Lerp(from, to, t));  // ★値を入れる先:まぶたの閉じ具合
            yield return null;
        }
        SetBlink(to); // 誤差をなくすため最後にきっちり
    }

    private void SetBlink(float weight)
    {
        values[blinkKey] = weight;   // 一覧の中の値を書き換えて……
        proxy.SetValues(values);      // ……まとめて1回で渡す(公式の推奨)
    }

    [ContextMenu("まばたきテスト")]
    private void TestBlink() => StartCoroutine(BlinkOnce());
}

Move() を見てください。3-1の Fade() と、行の並びまで同じです。違うのは、値を入れる先が canvasGroup.alpha から SetBlink() に変わったことだけです。

■ アタッチ

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

  2. AvatarBlink をドラッグしてアタッチ

  3. Proxy 欄は空のままで構いません(Awake で自動で探します)

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

  • 三人称(TPS)のまま、カメラをアバターの正面に回して、顔を見る。数秒おきに、まばたきをする

  • 再生したまま model_test を選択し、Avatar Blink を右クリック ▸ まばたきテスト で、すぐに1回まばたきする

  • 両目が同時に閉じる

■ よくあるつまずき

一人称(FPS)にすると、まばたきは見えません。 第5弾で、一人称の間はアバターの体を「影だけ描く」設定にしたからです。故障ではありません。

📝 まばたきの間隔は、2.5〜6秒のランダムにしました。人のまばたきは平均すると数秒に1回ですが、等間隔だと機械に見えます。 ばらつかせるだけで、ぐっと生き物らしくなります。


3-3. 静けさ(全体の音量を引く)

3つ目は音です。映画やゲームで、大事な場面の直前に、ふっと音が引くことがあります。あの「静けさ」を作ります。実際に使うのは後編のクライマックスですが、部品はここで作っておきます。

動かす値は AudioListener.volume。ゲーム全体の音量を 0〜1 で決める値です。

■ なぜ、ミキサーのスライダーではないのか
第10弾後編で、BGM・効果音・環境音の音量を、ミキサーの3つの値で動かせるようにしました。「全部を静かにしたいなら、あの3つを下げればいいのでは?」と思うかもしれません。

それをやると、プレイヤーがポーズメニューで決めた音量を、演出が勝手に書き換えてしまいます。 演出が終わって元に戻すとき、「元」がいくつだったのかも、覚えておく必要が出てきます。

AudioListener.volume は、それとは別の場所にあります。ミキサーを通ったあとの、耳の直前にかかる最後の倍率です。ここを動かすぶんには、プレイヤーの設定には一切触れません。

■ AudioManager を改修する
AudioManager.cs を開いて、クラスの一番最後の } の手前(SetMuffled メソッドの下)に、次のかたまりを足します。すぐ上にある、BGM用の FadeSource と見比べてみてください。音量を入れる先が違うだけで、同じ形です。

    // ==== 💡 第11弾で追加:全体の音量を、なめらかに上げ下げする ====
    private Coroutine masterRoutine;

    // 全体の音量を target(0〜1)へ、duration 秒かけて動かす(投げっぱなしでよいとき用)
    public void FadeMaster(float target, float duration)
    {
        if (masterRoutine != null) StopCoroutine(masterRoutine);
        masterRoutine = StartCoroutine(FadeMasterRoutine(target, duration));
    }

    // 同じことをして、終わるまで待つ(演出の中で「下がりきってから次へ」と書きたいとき用)
    public IEnumerator FadeMasterRoutine(float target, float duration)
    {
        // 「値を、時間をかけて from から to へ動かす」型。今回入れる値は、全体の音量
        float from = AudioListener.volume;
        float time = 0f;
        while (time < duration)
        {
            time += Time.unscaledDeltaTime;                      // 経過時間を足す(すぐ上の FadeSource と同じく、ポーズ中でも進む時計で)
            float t = time / duration;                           // 0 → 1 の進み具合
            AudioListener.volume = Mathf.Lerp(from, target, t);  // ★値を入れる先:全体の音量
            yield return null;
        }
        AudioListener.volume = target; // 誤差をなくすため最後にきっちり
        masterRoutine = null;
    }

    [ContextMenu("静けさテスト(下げる→3秒→戻す)")]
    private void TestHush() => StartCoroutine(TestHushRoutine());

    private IEnumerator TestHushRoutine()
    {
        yield return FadeMasterRoutine(0.15f, 1.0f);      // 1秒かけて、15%まで引く
        yield return new WaitForSecondsRealtime(3f);      // 3秒、静けさを保つ
        yield return FadeMasterRoutine(1f, 1.5f);         // 1.5秒かけて戻す
    }

三度目の、同じ形です。第10弾後編で FadeSource のコメントに「第4弾の骨格」と書きましたが、あの骨格は、ここでも現役です。

■ 前の章で足した「関所の1行」の意味
AudioListener.volume には、1つ性質があります。static、つまりゲーム全体で1つだけの値で、シーンを切り替えても残ります。

静けさを作ったまま、エンディングへ進んだとしましょう。戻し忘れると、エンディングでも、タイトルへ戻っても、ずっと音が小さいままです。しかも原因が演出のスクリプトの中にあるので、タイトル画面をいくら調べても分かりません。

だから前の章で、ScreenFader の関所に AudioListener.volume = 1f; を足しておきました。どんな経路でシーンを出ても、必ず元に戻ります。第10弾前編の「構造的な予防」の、続きです。戻し忘れに気をつける、のではなく、戻し忘れようがない場所に置く。

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

  1. 再生したまま、ヒエラルキーの一番下にある DontDestroyOnLoad の中の AudioManager を選択

  2. インスペクターの Audio Manager を右クリック ▸ 静けさテスト(下げる→3秒→戻す)

  • BGMも、環境音も、足音も、番人の駆動音も、全部そろって小さくなり、3秒後に戻る

  • ポーズメニューの音量スライダーの位置は、動いていない

関所の1行が働くところは、この章では確かめられません。 このテストは、自分で音量を戻すところまで面倒を見るからです。関所が本当に効いてくるのは、後編で、静けさを「下げたまま」エンディングへ進む場面です。確認は、そのときに行います。

【✅要確認:公開前に実機で確かめる】第10弾後編で、すべての音をミキサー経由に切り替えました。AudioListener.volume がミキサーを通した音にも効いて、全部そろって下がることを確認し、結果をここに1行で書く。もし一部の音が残るなら、この節の設計を見直す。


この章のまとめ

3つの部品を作りました。コードを見比べると、真ん中の while の中身は、3つともほぼ同じです。

「演出を作る」とは、多くの場合、「何の値を、どのくらいの時間で、どこからどこへ動かすか」を決めることでした。そして、グローバルな値(AudioListener.volume)を動かすときは、戻し忘れない構造を先に作る。これも今回の学びです。

次の章では、この部品を並べて、最初の見せ場を組み立てます。


第4章 導入演出を組む(強制歩行・視点誘導・一度きり)

主旨

部品がそろったので、最初の見せ場を作ります。ゲームを始めた直後の、5秒ほどの導入演出です。

市販のゲームでよくある、「最初の数秒だけ勝手に動いて、行き先を見せてくれる」あれです。いまのこの遺跡は、始まった瞬間に操作できますが、どちらへ行けばいいのかは、誰も教えてくれません。

■ 絵コンテ
先に、何が起きるかを時系列で決めます。

5弾で PlayerLocomotion に InputLocked を仕込み、第6弾で「個別に移動だけ止めたいとき用(例:特定演出中)」と役割を書きました。第8弾で、霧で戻す1秒ちょっとの間だけ使いましたが、本格的な出番は今回が初めてです。

新しく作るものは3つ。易しい順に進めます。


4-1. 一度きりの旗:OneShotFlag

導入演出は、初回だけ流します。2周目、3周目と遊ぶ人を、毎回5秒待たせるのは不親切だからです。

「もう見たかどうか」を覚えておく場所は、第6弾と第10弾前編で使った PlayerPrefs です。ゲームを終了しても消えない、小さなコインロッカーでした。ここに「導入は見た」という印を1つ入れておきます。

同じ仕組みを、後編でも使います。使い回せるよう、小さな道具にしておきます。

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

// 「一度きり」を管理する小さな道具。PlayerPrefs に「済んだ」の印を付けるだけ。
// static クラスなので、どこにもアタッチしません。
public static class OneShotFlag
{
    // もう済んでいるか
    public static bool IsDone(string key)
    {
        return PlayerPrefs.GetInt(key, 0) == 1;   // 印が無ければ 0 が返る
    }

    // 「済んだ」の印を付ける
    public static void MarkDone(string key)
    {
        PlayerPrefs.SetInt(key, 1);
        PlayerPrefs.Save();                       // 第6弾の規律:書いたら、すぐ保存
    }

    // 印を消す(開発中に、もう一度見たいとき用)
    public static void Clear(string key)
    {
        PlayerPrefs.DeleteKey(key);
        PlayerPrefs.Save();
    }
}

PlayerPrefs には bool をしまえないので、int の 0 と 1 で代用します。第10弾前編の HasCleared と同じやり方です。


4-2. アバターを自動で歩かせる

■ 目玉つまずき①:外から動かすと、棒立ちのまま滑る
アバターを歩かせるだけなら簡単そうです。
最初は「演出のスクリプトから、CharacterController.Move() を毎フレーム呼べばいい」と思ってました。

ところが、PlayerLocomotion のコードを読み返すと、これではうまくいかないことが分かります。第5弾で入れたロックの部分は、こうなっています。

if (InputLocked || UiGate.IsOpen)
{
    _animator.SetFloat(_speedHash, 0f); // 走行アニメを止める
    ApplyGravityOnly();                 // 重力のみ適用
    _animator.SetBool(_groundedHash, _controller.isGrounded);
    return;
}

ロック中も、PlayerLocomotion は休んでいません。毎フレーム「Speedは0です」とAnimatorに報告し続けています。 ここへ別のスクリプトが「Speedは2です」と報告しても、同じフレームの中で0に上書きされます(どちらが後に実行されるかは、運次第です)。

結果、位置は動くのに、アニメーションは待機のまま。棒立ちでスーッと滑るアバターの出来上がりです。

🧩 教訓:1つのものを動かす係は、1人にする。 アバターの移動とアニメーションの係は PlayerLocomotion です。演出で歩かせたいなら、外から手を出すのではなく、係に頼むべきです。PlayerLocomotion に「自動で歩いて」という頼み口を足します。

■ PlayerLocomotion を改修する

PlayerLocomotion.cs の、改修後の全体です。変わったのは3か所です。

// ==========================================================
// 📄 スクリプト:PlayerLocomotion.cs(第11弾前編・改修後・全体)
// ==========================================================
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が内部で使うメモ

    // 💡 第11弾で追加:演出用の「自動歩行」
    private bool _autoWalking;      // 自動歩行の最中か
    private Vector3 _autoDirection; // 歩く向き(水平)
    private float _autoSpeed;       // 歩く速さ

    // 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)
        {
            // 💡 第11弾で追加:自動歩行の最中なら、ここで歩かせて帰る。
            //    下の「Speedを0にする」行に届く前に return するのがポイントです。
            if (_autoWalking)
            {
                AutoWalkStep();     // 横の移動・体の向き・歩きのアニメ
                ApplyGravityOnly(); // 重力は、もとからある処理に任せる
                _animator.SetBool(_groundedHash, _controller.isGrounded);
                return;
            }

            _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);
    }

    // ==== 💡 第11弾で追加:演出用の「自動歩行」 ====

    // 演出から呼ぶ:この向きへ、この速さで、自動で歩き始める(InputLocked 中だけ働きます)
    public void BeginAutoWalk(Vector3 worldDirection, float speed)
    {
        worldDirection.y = 0f;                             // 上下の成分は捨てる
        if (worldDirection.sqrMagnitude < 0.0001f) return; // 向きが決まらなければ何もしない
        _autoDirection = worldDirection.normalized;
        _autoSpeed = speed;
        _autoWalking = true;
    }

    // 演出から呼ぶ:自動歩行をやめる
    public void EndAutoWalk()
    {
        _autoWalking = false;
    }

    // 1フレームぶんの自動歩行
    private void AutoWalkStep()
    {
        // 横に進む(縦の動き=重力は、このあと ApplyGravityOnly() が受け持ちます)
        _controller.Move(_autoDirection * (_autoSpeed * Time.deltaTime));

        // 進む向きへ、体を滑らかに向ける(ふだんの移動の「4.」と同じ書き方)
        float targetAngle = Mathf.Atan2(_autoDirection.x, _autoDirection.z) * Mathf.Rad2Deg;
        float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle,
                                            ref _turnSmoothVelocity, rotationSmooth);
        transform.rotation = Quaternion.Euler(0f, angle, 0f);

        // Animatorへ「この速さで歩いています」と報告する(ふだんの移動の「7.」と同じ)
        _animator.SetFloat(_speedHash, _autoSpeed);
    }

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

■ コードの要点

ロックの分岐の「中」に入れたのがポイントです。 自動歩行のときは、もとの「Speedを0にする」行に届く前に return します。だから上書きされません。自動歩行が終われば、いつものロックの処理に戻り、アバターは待機の姿勢に戻ります。

Move() を2回呼んでいます。 AutoWalkStep() の中で横に1回、ApplyGravityOnly() の中で縦に1回。CharacterController.Move()は、1フレームに何回呼んでも構いません。重力の計算は第5弾のものをそのまま使うので、書き直さずに済みます。接地の判定(isGrounded)は「最後に呼んだ Move」の結果を返すので、縦を後に呼ぶ順番にしています。

体の向きと、Animatorへの報告は、ふだんの移動と同じ書き方です。 AutoWalkStep() の中身を、Update() の「4.」と「7.」と見比べてください。入力の代わりに、演出から渡された向きと速さを使っているだけです。

Speed に渡す値は、歩く速さそのものです。 第1弾のBlend Treeは、「0で待機、2で歩き、6で走り」でした。速さ2で歩かせれば、Speedも2。歩きのアニメーションがぴったり再生されます。

足音は、何もしなくても鳴ります。 歩きのアニメーションが再生されれば、第10弾後編で仕込んだアニメーションイベントが足音を鳴らしてくれます。第2章で UiGate とは別の旗を作ったのは、このためでした。


4-3. カメラを、スクリプトから動かす

■ 目玉つまずき②:Cinemachine 3系のカメラは、回しても戻される
次はカメラです。「カメラの Transform を Rotate で回せばいい」と思いますよね。三人称のカメラは、第1弾で Cinemachine に任せました。その場合、Transform を回しても、次のフレームには元に戻されます。 カメラの位置と向きは、毎フレーム Cinemachine が計算し直して、上書きしているからです。

これも、さっきと同じ話です。カメラを動かす係は Cinemachine。だから、係に頼みます。

Cinemachine の公式マニュアルには、こう書かれています(要約)。

Cinemachine のカメラは、ユーザーの入力を直接は処理しない。代わりに「軸」を公開していて、その軸は、スクリプト・アニメーション・ユーザーの入力の、どれで動かしてもよい。

第1弾で使った Orbital Follow は、「水平の軸」と「垂直の軸」を持っています。マウスを動かすとカメラが回るのは、Cinemachine Input Axis Controller が、マウスの動きをこの軸の値に流し込んでいるからです。ということは ……

  1. Input Axis Controller を一時的に止める(マウスからの流し込みを止める)

  2. スクリプトが、水平の軸の値を直接書き換える

  3. 終わったら、Input Axis Controller を再開する

これで、カメラを好きな向きへ回せます。水平の軸は、APIリファレンスによると「現在の水平の回転を表す。値は度で、上向きの軸まわりの回転」です。度で指定できるので、分かりやすいです。

■ スクリプト:TpsCameraSteer.cs

// ==========================================================
// 📄 スクリプト:TpsCameraSteer.cs(新規)
// ==========================================================
using System.Collections;
using UnityEngine;
using Unity.Cinemachine;   // Cinemachine 3系の名前空間

// 三人称カメラの「操舵係」。CinemachineCamera のオブジェクトにアタッチします。
// Orbital Follow の水平の軸を、スクリプトから動かします。
public class TpsCameraSteer : MonoBehaviour
{
    [SerializeField] private CinemachineOrbitalFollow orbital;
    [SerializeField] private CinemachineInputAxisController axisController;

    // コンポーネントを付けた瞬間に、同じオブジェクトから自動で探して埋める
    private void Reset()
    {
        orbital = GetComponent<CinemachineOrbitalFollow>();
        axisController = GetComponent<CinemachineInputAxisController>();
    }

    // マウスでカメラを回せるようにする/できなくする
    public void SetPlayerControl(bool on)
    {
        if (axisController != null) axisController.enabled = on;
    }

    // カメラを、worldDirection の方角を向くまで、duration 秒かけて回す
    public IEnumerator TurnToward(Vector3 worldDirection, float duration)
    {
        // いまカメラが向いている方角(水平)
        Vector3 forward = Camera.main.transform.forward;
        forward.y = 0f;
        worldDirection.y = 0f;
        if (forward.sqrMagnitude < 0.0001f || worldDirection.sqrMagnitude < 0.0001f) yield break;

        // 「あと何度、どちら回りに回せばいいか」(右回りがプラス)
        float delta = Vector3.SignedAngle(forward, worldDirection, Vector3.up);

        float from = orbital.HorizontalAxis.Value;   // 軸のいまの値(度)
        float to   = from + delta;                   // 回し終えたときの値

        // 「値を、時間をかけて from から to へ動かす」型。今回入れる値は、軸の角度
        float time = 0f;
        while (time < duration)
        {
            time += Time.deltaTime;                                // 経過時間を足す
            float t = Mathf.SmoothStep(0f, 1f, time / duration);   // 0 → 1 の進み具合(ゆっくり始まり、ゆっくり止まる)
            orbital.HorizontalAxis.Value = Wrap(Mathf.Lerp(from, to, t)); // ★値を入れる先:軸の角度
            yield return null;
        }
        orbital.HorizontalAxis.Value = Wrap(to); // 誤差をなくすため最後にきっちり
    }

    // 角度を -180〜180 の範囲に収める(軸の値の範囲に合わせる)
    private static float Wrap(float degrees) => Mathf.DeltaAngle(0f, degrees);
}

四度目の、同じ形です。 値を入れる先が、今度は orbital.HorizontalAxis.Value になりました。

Mathf.SmoothStep が新顔です。 これまでの Lerp は、最初から最後まで同じ速さで進みました。カメラでそれをやると、急に動き出して急に止まるので、機械的に見えます。SmoothStep を1枚かませると、ゆっくり動き出して、ゆっくり止まります。進み具合(0〜1)を、なめらかなS字に変換してくれるだけの関数です。

「いまの向きとの差」で計算している理由。 軸の値が「どの方角を0度とするか」は、Orbital Follow の設定によって変わります。そこを調べる代わりに、「いまカメラが向いている方角から、あと何度回すか」を求めて、いまの値に足しています。これなら設定に左右されません。

■ アタッチ

  1. ヒエラルキーで、第1弾で作った三人称カメラ(名前を変えていなければ CinemachineCamera)を選択

  2. TpsCameraSteer をアタッチ

  3. インスペクターで、Orbital 欄と Axis Controller 欄が、自動で埋まっていることを確認

💡 自動で埋まるのは、Reset() のおかげです。Reset() は、コンポーネントを付けた瞬間にUnityが呼んでくれるメソッドです。同じオブジェクトにある Orbital Follow と Input Axis Controller を探して、入れてくれます。もし空のままなら、CinemachineCamera 自身を、2つの欄へそれぞれドラッグしてください。


4-4. 演出の最中は、視点の切り替えも止める

導入演出は三人称で見せます。ゲームの開始時は必ず三人称なので、そこは問題ありません。ただ、演出の5秒の間に V を押されると、一人称に切り替わってしまい、カメラの操舵が空振りします。

CameraModeSwitcher.cs を開いてください。Update() の最初の行。ここを書き換えます。

// 変更前
if (UiGate.IsOpen) return; // 💡 UI表示中はV切替もマウスルックも止める

 // 変更後
 if (UiGate.IsOpen || StageGate.IsPlaying) return; // 💡 第11弾で変更:演出の最中も、V切替とマウスルックを止める

ランタン(L)だけは、演出の最中も点け消しできます。 LanternController も UiGate しか見ていないためです。導入の5秒の間にランタンを消されても、困ることは何もないので、今回はそのままにしました。ここも止めたい方は、LanternController の Update() の最初の行を、上とまったく同じように書き換えてください。


4-5. 組み立てる:IntroSequence

材料がそろいました。絵コンテのとおりに、上から順番に並べます。

■ シーンに目印を置く
歩く先と、カメラを向ける先を、空のオブジェクトで指定します。数字をスクリプトに書き込むより、シーンの中で目で見て動かせるほうが、調整が楽だからです。

  1. ヒエラルキーの何もないところを右クリック ▸ 空のオブジェクトを作成。名前を IntroWalkTarget に

  2. もう1つ作り、名前を IntroLookTarget に

  3. さらにもう1つ作り、名前を IntroSequence に(位置はどこでも構いません)

目安です。適宜調整してください。

2つの目印の高さ(Y)は、いくつでも構いません。スクリプトは、水平の向きと距離だけを見ています。

💡 シーンビューで、アバターから IntroWalkTarget まで、まっすぐ線を引いたつもりで眺めてください。その線の上に物があると、アバターは引っかかって足踏みします(その場合も、最長4秒で切り上げるので、ゲームが止まることはありません)。

■ スクリプト:IntroSequence.cs

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

// ゲーム開始直後の導入演出。初回だけ流れます。
// これまでに作った部品を、上から順番に呼んでいるだけです。
public class IntroSequence : MonoBehaviour
{
    private const string Key = "IntroPlayed";   // PlayerPrefs の鍵

    [Header("登場人物")]
    [SerializeField] private PlayerLocomotion player;
    [SerializeField] private TpsCameraSteer cameraSteer;

    [Header("目印")]
    [SerializeField] private Transform walkTarget;   // ここまで歩く
    [SerializeField] private Transform lookTarget;   // ここへカメラを向ける

    [Header("間(ま)の調整")]
    [SerializeField] private float startDelay     = 0.8f;   // 暗転が明けるのを待つ(フェードは0.6秒)
    [SerializeField] private float walkSpeed      = 2f;     // 歩く速さ(第1弾の歩きと同じ)
    [SerializeField] private float maxWalkSeconds = 4f;     // 着かなくても、これで切り上げる
    [SerializeField] private float turnSeconds    = 1.6f;   // カメラを回す時間

    [Header("開発用")]
    [SerializeField] private bool playEveryTime = false;    // オンにすると、毎回流れる

    // Start をコルーチンにする書き方(第10弾前編の BootLoader と同じ)
    private IEnumerator Start()
    {
        // ---- 0. もう見た人には、流さない ----
        if (!playEveryTime && OneShotFlag.IsDone(Key)) yield break;

        // ---- 0.5. 割り当て忘れの保険:何かを止める「前」に確かめる ----
        // 旗を立てたあとでエラーになると、番人も操作も止まったまま戻らなくなるため
        if (player == null || cameraSteer == null || walkTarget == null || lookTarget == null)
        {
            Debug.LogWarning("IntroSequence: インスペクターの割り当てが足りないので、導入演出を飛ばします");
            yield break;
        }

        // ---- 1. 止める ----
        StageGate.IsPlaying = true;             // 番人と、視点の切り替えを止める
        player.InputLocked = true;              // プレイヤーの操作を止める
        cameraSteer.SetPlayerControl(false);    // マウスでカメラが回らないようにする

        yield return new WaitForSeconds(startDelay);   // 暗転が明けるまで待つ

        // ---- 2. 歩かせる ----
        player.BeginAutoWalk(walkTarget.position - player.transform.position, walkSpeed);

        float elapsed = 0f;
        while (elapsed < maxWalkSeconds)
        {
            Vector3 rest = walkTarget.position - player.transform.position;
            rest.y = 0f;
            if (rest.sqrMagnitude < 0.3f * 0.3f) break;   // 30cmまで近づいたら到着
            elapsed += Time.deltaTime;
            yield return null;
        }
        player.EndAutoWalk();

        // ---- 3. カメラを回して、行き先を見せる ----
        yield return cameraSteer.TurnToward(lookTarget.position - player.transform.position, turnSeconds);

        // ---- 4. きらめく ----
        if (ScreenFlash.Instance != null)
            yield return ScreenFlash.Instance.FlashRoutine();

        // ---- 5. 返す(止めたものは、止めた順の逆に戻す) ----
        cameraSteer.SetPlayerControl(true);
        player.InputLocked = false;
        StageGate.IsPlaying = false;

        // ---- 6. 「見た」の印を付ける ----
        OneShotFlag.MarkDone(Key);
    }

    [ContextMenu("導入演出のフラグを消す")]
    private void ClearFlag()
    {
        OneShotFlag.Clear(Key);
        Debug.Log("導入演出のフラグを消しました。次にMainを始めると、もう一度流れます。");
    }
}

【 コードの解説 】

  • Start() の中身は、絵コンテそのものです
    「止める → 歩かせる → 回す → きらめく → 返す → 印を付ける」。コルーチンの良さは、時間のかかる出来事を、上から下へ、起きる順に書けることです。yield return のたびに「これが終わるまで待って」と言っているだけです。

  • 到着の判定は、距離の2乗で比べています
     
    第9弾の番人の視界で使ったのと同じ考え方です。sqrMagnitude(距離の2乗)と、0.3×0.3 を比べれば、重い平方根の計算をせずに「30cm以内か」が分かります。

  • maxWalkSeconds は保険です
    途中で何かに引っかかって着けなくても、4秒で必ず次へ進みます。演出は、失敗しても必ず終わるように作る。 終わらない演出は、操作が永久に戻らない、という最悪の不具合になります。

  • 止める「前」に、割り当てを確かめています
    これは、書いている途中で気づいた穴でした。旗を立てて、操作を止めて、そのあとで割り当て忘れのエラーが出ると、コルーチンはそこで止まります。旗は立ったまま、操作は止まったまま。番人は凍りつき、プレイヤーは一歩も動けない、という状態で固まります。割り当て忘れは、初心者が一番やりがちな失敗です。第8弾の GuideSpirit が、経由点が未設定なら警告を出して何もしないのと同じ作法で、何かを止める前に確かめます。

  • 印を付けるのは、最後です
    最初に付けてしまうと、演出の途中でゲームを終了した人は、二度と導入を見られなくなります。

■ 割り当て
ヒエラルキーで IntroSequence を選択し、スクリプトをアタッチしてから、次のように割り当てます。

■ 動作確認
ゲーム開始「▶」で確認します。Main シーンから直接再生して構いません。

  • 暗転が明けると、アバターが自動で歩き出す。歩きのアニメーションが再生され、足音も鳴る(滑っていない)

  • 立ち止まると、カメラがゆっくり回って、奥の通路を映す

  • 画面がきらめき、操作できるようになる

  • その間、W A S D も、マウスでのカメラ操作も、V も効かない(ランタンの L だけは効きます。4-4の📝のとおりです)

  • いったん再生を止めて、もう一度再生する。今度は、導入が流れない

■ もう一度見たいとき
一度きりの演出は、開発中の確認が面倒です。そのために、消す手段を用意しておきました。

🛠️ ヒエラルキーで IntroSequence を選択 ▸ インスペクターの Intro Sequence の見出しを右クリック(または右端の ⋮)▸ 導入演出のフラグを消す

これは再生中でなくても使えます。調整を繰り返す間は、インスペクターの Play Every Time をオンにしておくと、毎回流れます。公開前に、必ずオフに戻してください。

📝 第6弾で紹介した 編集 ▸ すべてのPlayerPrefsを消去 でも消えます。ただしそちらは、刻んだ名前も、集めた秘宝も、音量の設定も、全部まとめて消えます。1つだけ消す手段を持っておくと、ぐっと楽になります。

■ よくあるつまずき

■ この章のまとめ

  1. 動かしたいものには、必ず「係」がいる。 アバターは PlayerLocomotion、カメラは Cinemachine。外から直接いじらず、係に頼み口を作る

  2. Cinemachine 3系のカメラは、軸の値をスクリプトから書き換えて動かす。その間は Input Axis Controller を止める

  3. 演出は、失敗しても必ず終わるように作る(時間切れの保険)

  4. 一度きりの演出には、消す手段も一緒に作る


第5章 しずくと塵、精霊の光の尾(パーティクルシステム)

主旨

■ パーティクルシステム

山場を越えたので、この章は息抜きです。スクリプトはほとんど書きません。Unityのパーティクルシステムを使って、遺跡に「空気」を足します。

パーティクルシステムは、小さな粒を大量に出して、煙・火花・雨などを表現する機能です。粒の1つ1つを自分で動かす必要はなく、「どこから、どのくらい、どんな速さで出すか」を決めるだけで、あとはUnityが面倒を見てくれます。

■ 予告の「雨」について
 
前回の予告で「雨と粉塵」と書きました。ですが、作り始めてから気づきました。この遺跡には、空がありません。

第3弾で作った区画は、どれも天井まで閉じた箱です。雨を降らせるには天井に穴が要りますが、穴を開けると、その向こうに昼間の青空が見えてしまいます。しかも広間は、入口と宝物庫で同じPrefabを使っているので、片方だけ開けることもできません。

そこで、屋内の遺跡で自然に起きるものに読み替えます。天井から落ちる「しずく」と、あたりを漂う「塵」です。雨を期待されていた方、すみません。

📝 項目名の書き方について。 パーティクルシステムの項目名は、日本語化の版によって訳が違ったり、英語のまま残っていたりします(Unityの日本語マニュアルも、項目名は英語のままです)。この章では「日本語(英語)」の形で両方を書きます。見つからないときは、英語名で探してください。

■ VFX Graph は使わないの?

Unityには、もう1つ、VFX Graph という新しい仕組みがあります。何百万もの粒を出せる強力なものですが、グラフィックスの高度な機能(コンピュートシェーダー)を必要とするため、この連載の最終目標であるブラウザ版(WebGL)では、現在は動きません。今回は、昔からあって、どこでも動くパーティクルシステムを使います。


5-1. 粒のマテリアルを作る

最初に、粒の「見た目」を決めるマテリアルを2枚作ります。

■ 目玉つまずき:粒がピンクの四角になる
パーティクルシステムを作ると、最初からそれらしい白い粒が出ます。マテリアルを作らなくても動くので、つい省きたくなります。ですが、この連載のプロジェクトでは、自分でマテリアルを作ることを強く勧めます。 理由は2つです。

  1. ピンクになることがある
     
    第1弾で、ピンク色は、シェーダーがいまの描画の仕組み(URP)と合っていない印だ、と学びました。このプロジェクトは、最初は別の仕組みで作ってから、途中でURPに切り替えた経緯があります(第1弾)。そのため、最初から付いてくるマテリアルが、URPに合っていない場合があります。

  2. ビルドで消えることがある
    第4弾の霧、第7弾の目の光で、「エディタでは見えるのに、ビルドすると消える」という罠を、2度学びました。Unityは、ビルドを軽くするために、「使われていない」と判断したシェーダーの機能を削ります。プロジェクトの中のマテリアルが、そのシェーダーをちゃんと使っている状態にしておくことが、いちばん確実な予防になります。

■ 手順

ParticleSoftMat
  1. プロジェクトウィンドウで Assets/Materials を右クリック ▸ 作成 ▸ マテリアル。名前を ParticleSoftMat に

  2. インスペクターの一番上の シェーダー(Shader) をクリック ▸ Universal Render Pipeline ▸ Particles ▸ Unlit

  3. 次のように設定

ParticleSoftMatの次にParticleGlowMatを作成します。

ParticleGlowMat

ParticleSoftMat を選択して Cmd + D(Windowsは Ctrl + D)で複製。名前を ParticleGlowMat に。ParticleGlowMat の ブレンディングモード だけを 加算(Additive) に変更

Unlit(アンリット)は「光の影響を受けない」という意味です。ランタンの光が当たっても当たらなくても、同じ明るさで描かれます。粒は小さいので、これで十分です。第4弾で悩まされた「1つの物に当たるライトの数の上限」にも、関わらずに済みます。


5-2. 天井から落ちる、しずく

■ 作る

  1. ヒエラルキーの何もないところを右クリック ▸ エフェクト ▸ パーティクルシステム。名前を Drip_A に

  2. 位置を、最初の通路の天井のすぐ下に。位置 (1.2, 2.95, 9)、回転 (0, 0, 0)

💡 作った瞬間、白い粒が上へ吹き出します。新しく作ったパーティクルシステムは、回転のXに -90 が入っていて、上向きに噴き出す設定になっているからです。回転を0にして、これから設定を変えていきます。

シーンビューの右下に、小さな操作パネル(パーティクルエフェクト)が出ます。「再生」「再起動」「停止」で、ゲームを再生しなくても、その場で動きを確かめられます。

■ 一番上のかたまり(メインモジュール)

インスペクターの Particle System の、一番上のかたまりです。以下のように設定します。

生存期間の0.8秒は、計算で出した値です。 天井から床まで、約3メートル。物が3メートル落ちるのにかかる時間は、およそ0.78秒です。だから0.8秒で消せば、ちょうど床に着くあたりで消えます。 長すぎると床を突き抜けて落ち続け、短すぎると空中で消えます。床に当たったかどうかを調べる機能(衝突モジュール)もありますが、計算で済むなら、そのほうが軽いです。

■ 放出(Emission)
見出しの「放出」をクリックして開きます(左のチェックは、最初から入っています)。

1秒に0.7粒。つまり、1.4秒に1粒くらい、ぽた……ぽた……と落ちます。

■ 形状(Shape)

30センチ四方の、薄い箱の中のどこかから落ちます。毎回ほんの少し位置がずれるので、自然に見えます。

■ レンダラー(Renderer)
一番下にある「レンダラー」を開きます。

ストレッチビルボードは、粒を、進んでいる向きに引き伸ばして描くモードです。速く落ちるほど縦に伸びるので、丸い点が、落ちるにつれて水滴らしい筋になります。

■ 増やす
Drip_A を選択して Cmd + D で複製し、好きな場所に置いてください。目安は3〜5か所です。

💡 複製したら、それぞれの 時間ごとの率 を 0.5、0.9 などと少し変えておくと、全部が同じリズムで落ちる不自然さが消えます。

しずくの「ぽちゃん」という音は付けていません。第4弾の環境音に、すでに水の気配が入っているためです。付けたい方は、第10弾後編の GolemHum と同じ作り方(3Dの音をループ再生)で、Drip_A に Audio Source を足せます。


5-3. 漂う塵

次は、空中をゆっくり漂う、細かな塵です。しずくと違って、プレイヤーがどこへ行っても、まわりに漂っていてほしい。そこで、カメラに付いて回るようにします。

■ 作る場所が大事です

  1. ヒエラルキーで、Main Camera を右クリック ▸ エフェクト ▸ パーティクルシステム。名前を DustMotes に

  2. DustMotes が Main Camera の子になっていることを確認

  3. 位置 (0, 0, 0)、回転 (0, 0, 0)

アバター(model_test)の子にしてはいけません。 第5弾で、一人称の間は「アバターの子にある、描画するものすべて」を、影だけ描く設定に切り替えるようにしました。塵をアバターの子にすると、この処理に巻き込まれて、一人称にした瞬間に塵が消えます。 第10弾後編で、ミニマップの現在地の印をアバターの子にしなかったのと、同じ理由です。

■ メインモジュール

「6 〜 10」のように、幅のある値の入れ方。 入力欄の右端にある小さな ▼ をクリックし、2つの定数間でランダム(Random Between Two Constants) を選ぶと、入力欄が2つに増えます。粒ごとに、この範囲のどこかの値がランダムに選ばれます。全部の粒が同じ大きさ・同じ寿命だと、作り物に見えます。

■ 放出(Emission)

■ 形状(Shape)

カメラを中心にした、10×3×10メートルの箱の中に、粒が生まれます。箱の一部は壁の向こうへはみ出しますが、壁の裏の粒は壁に隠れて見えないので、気にしなくて大丈夫です。

■ 生存期間の色(Color over Lifetime)

このままだと、粒が「パッ」と現れて「パッ」と消えます。ふわっと現れて、ふわっと消えるようにします。

  1. 見出し「生存期間の色」の左のチェックを入れて、開く

  2. 色の帯(白い横長のバー)をクリック。グラデーションエディターが開く

  3. バーの上側にある小さなつまみが、不透明度(アルファ)です。左端のつまみをクリックして、下の アルファ を 0 に

  4. 右端のつまみも、アルファを 0 に

  5. バーの上側の、真ん中あたり(位置 20% と 80% あたり)をクリックして、つまみを2つ足す。どちらもアルファを 255 に

これで、「透明 → 見える → 見える → 透明」という一生になります。

■ ノイズ(Noise)
まっすぐ進むだけでは、塵らしくありません。ゆらゆらと、あてもなく漂わせます。

見出し「ノイズ」にチェックを入れて開く

■ レンダラー(Renderer)

ビルボードは、粒をいつもカメラのほうへ向けて描くモードです。第9弾で、番人の頭上の「?」「!」を、いつもこちらへ向けたのと同じ考え方です。


5-4. 精霊に、光の尾と、ゆらぎを

第8弾で、「精霊の凝った演出は第11弾で」と書きました。その約束を果たします。いまの導きの精霊は、光る玉が、正解の道筋をまっすぐ飛んでいくだけです。これに光の尾を引かせ、ふわふわと上下に揺らします。

GuideSpirit は、ふだん非アクティブ(チェックが外れた状態)ですが、ヒエラルキーで選択すれば、そのまま編集できます。

■ 光の尾を作る

  1. ヒエラルキーで GuideSpirit を開き、その子の 光る玉(Sphere)を右クリック ▸ エフェクト ▸ パーティクルシステム。名前を SpiritTail に

  2. 位置 (0, 0, 0)、回転 (0, 0, 0)

メインモジュール

放出(Emission)

「距離ごとの率」は、1メートル動くごとに、何粒出すかです。時間ではなく、動いた距離で粒が出ます。

精霊が止まっている間は粒が出ず、飛び始めると尾を引く。まさに欲しかった動きです。

  • 形状(Shape)
    球(Sphere)、半径(Radius)0.05

  • 生存期間のサイズ(Size over Lifetime)
    チェックを入れて開き、サイズの横長のバーをクリック(線が赤くなります)。インスペクターのウィンドウの一番下の端に畳まれている「パーティクルシステムカーブ」の帯を、上へドラッグして広げます。広がった画面の一番下に並ぶ見本から、左から3番目の右下がりの線(1から0へ下がるもの)を選びます。粒が、だんだん小さくなって消えます。

右下下りにする

⚠️ バーをクリックしても何も起きないように見えますが、編集画面は下端に出ています。スクロールしても見えないので、帯を引き上げてください。

  • 生存期間の色(Color over Lifetime)
    チェックを入れて開き、色の帯をクリックします。グラデーションエディターのバーの上側、右端のつまみのアルファだけを 0 にします(左端は255のまま)。5-3の塵と違い、20%・80%のつまみは足しません。 塵は空中にいきなり生まれるので、ふわっと現れるようにしました。尾は精霊のすぐ後ろで生まれるので、生まれた瞬間を一番濃くしないと、玉と尾のあいだに隙間ができてしまいます。

  • レンダラー(Renderer)
    ビルボード(Billboard)、マテリアルは ParticleGlowMat(加算のほう)

■ 目玉つまずき:尾が、玉にくっついて回る

ここで、シミュレーション空間を ローカル(Local) のままにしていると、奇妙なことが起きます。尾が後ろへ伸びず、光る玉のまわりに粒がまとわりついて、毛玉のようになるのです。

🧩 シミュレーション空間は、「出たあとの粒が、誰を基準に動くか」を決める設定です。

尾は、「通ったあとに残る」から尾になります。精霊と一緒に動いてしまっては、尾になりません。5-3の塵で「ワールド」にしたのも同じ理由です。ローカルにすると、カメラを振るたびに、塵の群れが丸ごと一緒に振り回されます。

📝 Unityの公式マニュアルも、動いている物から粒を出す機能の説明で、「この機能は、シミュレーション空間がワールドのときだけ効く」と注意しています。動くものから粒を出すときは、ワールド。 これだけ覚えておけば大丈夫です。

■ ふわふわ揺らす:SpiritBob.cs

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

// 見た目を、ふわふわと上下に揺らす係。
// 精霊の「子」の光る玉(Sphere)にアタッチします。親(GuideSpirit)には付けません。
public class SpiritBob : MonoBehaviour
{
    [SerializeField] private float amplitude = 0.15f;   // 揺れ幅(メートル)。上下にこの分だけ動く
    [SerializeField] private float frequency = 0.8f;    // 1秒間に何往復するか

    private Vector3 basePosition;   // 揺れの中心(最初の位置)

    private void Awake()
    {
        basePosition = transform.localPosition;
    }

    private void Update()
    {
        // サイン波:時間が進むと、-1〜1 の間をなめらかに往復する
        float wave = Mathf.Sin(Time.time * frequency * Mathf.PI * 2f);
        transform.localPosition = basePosition + Vector3.up * (wave * amplitude);
    }
}

ヒエラルキーで、GuideSpirit の子の光る玉(Sphere)を選択して、アタッチします。

親の GuideSpirit に付けてはいけません。 SpiritBob は、「最初にいた場所」を中心にして、そこから上下に揺らすスクリプトです。これを親に付けると、親は毎フレーム、最初にいた場所へ引き戻されます。 第8弾の GuideSpirit が MoveTowards でせっせと前へ進めても、次の瞬間には元の位置です。精霊は、現れた場所から一歩も進めなくなります。


動作確認

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

  • 通路の天井から、ぽた、ぽた、としずくが落ちる。床のあたりで消える

  • あたりに、細かな塵がゆっくり漂っている。歩いても、カメラを回しても、塵の群れが一緒に付いてこない(その場に残っている)

  • V で一人称にしても、塵が消えない

  • 粒がピンクの四角になっていない

精霊は、45秒進めないと現れません。確認のために、一時的に短くします。

  1. ヒエラルキーの MazeDirector を選択し、Progress Tracker の Wait Seconds を 5 に

  2. ゲーム開始「▶」。入口の広間で、5秒じっと待つ

  3. 精霊が現れる。ふわふわ上下に揺れている。近づくと飛び始め、後ろに光の尾を引く。尾は、その場に残って、だんだん小さくなって消える

  4. 精霊が、経由点で足踏みせず、先へ進んでいく

  5. 余裕があれば、SpiritTail の シミュレーション空間 を、試しに ローカル に変えて、もう一度。尾が玉にまとわりつく「毛玉」が見られます。確かめたら、ワールドに戻します

  6. 確認が済んだら、Wait Seconds を 45 に戻す

📝 粒の数について。 しずくが10、塵が150、尾が60。全部合わせても数百で、負荷はごく小さいはずです(ブラウザ版での確認は、第13弾で行います)。それでも「最大パーティクル数」を必ず小さく設定しておくのは、うっかり「時間ごとの率」に1000と打ち込んだときの、安全装置になるからです。


この章のまとめ

  • 粒のマテリアルは、自分で作る。シェーダーは URP ▸ Particles ▸ Unlit

  • しずくの寿命は、落ちる時間から計算した

  • 動くものから粒を出すときは、シミュレーション空間をワールドに

  • 「距離ごとの率」を使うと、動いたときだけ粒が出る

  • 動かすのは本体、揺らすのは見た目


第6章 横切る列をやり過ごす

主旨

ここからは実践編です。通路の先を、巡回の列が横切ります。柱の陰でやり過ごせば、先へ進めます。見つかれば、霧に包まれて手前へ戻されます。ダンジョンものの定番、「見回りのすきを突く」場面です。

第9弾の番人は、視界を持ち、考え、追いかけてくる相手でした。今回の列は、もっと単純です。決まった道を歩いて、去っていくだけ。 そのぶん、作るのもずっと簡単です。

■ 設計:3つの部品に分ける

🧩 番人と同じ NavMesh Agent を使わない理由
NavMesh Agent は、「目的地が変わっても、障害物を避けて、自分で道を探す」ための仕組みです。今回の列は、現れる場所も、通る道も、消える場所も、全部決まっています。それなら、第8弾の精霊と同じ、点から点へまっすぐ動かすだけで足ります。複数のAgentを列のまま歩かせるのは、実はかなり難しい(互いに避け合って、列が崩れます)ので、易しいほうを選びます。

判定のルールも、割り切ります。 列が横切っている間、隠れ場所の中にいればセーフ、外にいたらアウト。番人のような視界の計算はしません。柱から体が半分はみ出していても、隠れ場所の中ならセーフです。厳密さよりも、遊ぶ人に「隠れた!」という手応えがあることを優先します。

■ 場所:分岐②の手前
舞台は、2本目の直線の通路と、その先の分岐②です。ここには番人が来ないので、2つの仕掛けが干渉しません。


6-1. 柱を立てる

第3弾の区画、第9弾の柱と、同じ作り方です。

  1. メニューの ゲームオブジェクト ▸ ProBuilder ▸ キューブ

  2. インスペクターの Pro Builder Shape の サイズ を (0.8, 3, 0.8) に

  3. 名前を Pillar_C に、位置を (5.6, 1.5, 27.5) に

通路の東の壁(X=6)に寄せて立てます。

📝 第8弾で書いたとおり、私の環境では通路の床が5センチ高くなっています(Y=1.55で床がそろいました)。ご自分の第9弾の柱と、同じ高さに合わせてください。


6-2. 隠れ場所:HideSpot

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

// 隠れ場所。「いま、プレイヤーが中にいるか」を答えるだけの係。
// Box Collider(トリガー)を付けた空のオブジェクトにアタッチします。
[RequireComponent(typeof(BoxCollider))]
public class HideSpot : MonoBehaviour
{
    // いま、プレイヤーが中にいるか
    public bool PlayerInside { get; private set; }

    // アタッチした瞬間に、Box Collider をトリガーにしておく
    private void Reset()
    {
        GetComponent<BoxCollider>().isTrigger = true;
    }

    private void OnTriggerEnter(Collider other)
    {
        // 第8弾の TeleportTrigger と同じ見分け方:CharacterController を持つのはプレイヤーだけ
        if (other.GetComponent<CharacterController>() != null) PlayerInside = true;
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.GetComponent<CharacterController>() != null) PlayerInside = false;
    }
}

■ シーンに置く

  1. ヒエラルキーで 空のオブジェクトを作成。名前を HideSpot_C に

  2. HideSpot をアタッチ(Box Collider が自動で付き、「トリガーにする」がオンになります)

  3. 位置とサイズを設定

柱のすぐ南(手前)側の、壁ぎわです。シーンビューで選択すると、緑の線の箱が見えます。柱の陰に立ったとき、体がこの箱に入ることを確かめてください。

💡 [RequireComponent(typeof(BoxCollider))] は、「このスクリプトには Box Collider が必須です」という宣言です。アタッチした瞬間に、Unityが Box Collider を自動で付けてくれます。第1弾の PlayerLocomotion(Character Controller が必須)や、第10弾後編の UiClickSound(Button が必須)と同じ作法です。


6-3. 列:MarchColumn

■ 列の一員を作る
番人より一回り小さい、衛兵を作ります。プリミティブを積むだけです(見た目は第12弾で整えます)。

体の色は番人と同じにして、「番人の仲間」に見せます。ただし目の色だけは変えます。 番人の目は、巡回で青白、警戒で黄、追跡で赤と、状態によって色が変わります。衛兵に同じ青白を使うと、プレイヤーは「番人が増えた」と思い、番人と同じように視界の角度を気にしてしまいます。実際の衛兵は、視界を持たず、隠れ場所の中か外かだけで判定する、まったく別の相手です。見た目で、別の存在だと伝えます。

色は紫にします。この遺跡で光っているものの色を並べると、こうなっています。

紫は、どれとも重なりません。特に黄は避けます。 番人が警戒しているときの色なので、衛兵が黄色い目をしていると、プレイヤーは「見つかりかけている」と誤解します。

■ 衛兵の目のマテリアルを作る
番人の目のマテリアルを複製して、色だけ変えます。 放出(光らせる設定)がオンのまま引き継がれるので、第7弾で学んだ「ビルドすると光らない」を心配せずに済みます。第10弾前編で、出口の光のマテリアルを、ランタンのマテリアルの複製から作ったのと同じ手です。

  1. プロジェクトウィンドウで Assets/Materials の GolemEye_Patrol を選択し、Cmd + D(Windowsは Ctrl + D)で複製

  2. できたマテリアルの名前を GuardEyeMat に変更

  3. インスペクターで、ベースマップの色を R 170 / G 90 / B 255 に

  4. 放出の欄の色(HDRの色)をクリックし、色を R 170 / G 90 / B 255 に。強度(Intensity)は変えない(番人の目と同じ明るさのまま)

💡 強度をそろえておくと、番人の目と並んだときに、明るさが同じで色だけ違う、という見え方になります。「別の種類だけど、同じ仲間」という感じが出ます。※ ただし、色が目立たない場合は適宜調整してください

■ 衛兵を組み立てる

  1. ヒエラルキーで 空のオブジェクトを作成。名前を ColumnGuard に。位置 (0, 0, 0)、スケール (1, 1, 1)

  2. その子に、3D オブジェクト ▸ キューブ を3つ作る

  • 3つとも、Box Collider を削除(コンポーネント名を右クリック ▸ コンポーネントを削除)

  • ColumnGuard を、プロジェクトウィンドウの Assets/Prefabs へドラッグして、Prefab にする(ヒエラルキーの ColumnGuard の名前が青くなります。これが、次の手順で使う1体目です)

今回のポイントはコライダーを全部外していることです。 残しておくと、列がプレイヤーを押しのけたり、隠れ場所のトリガーに反応したりします。今回の衛兵は「見えるだけ」の存在です。

なお、ヒエラルキーに残った ColumnGuard は、消さずにそのまま使います。 Prefabにした瞬間、ヒエラルキーに残ったほうも、そのPrefabのコピーに切り替わります(名前が青くなるのが印です)。これが、次の手順で使う1体目の衛兵です。

💡 目(Eye)を Z のプラス側に付けたので、Z のプラス方向が「前」です。スクリプトで進む向きへ体を向けると、目が前を向きます。

■ 列と、通り道を置く

  1. 空のオブジェクトを作成。名前を MarchColumn に。位置 (0, 0, 0)

  2. その子に、衛兵を 3体 置く。まず、ヒエラルキーにいる ColumnGuard を MarchColumn の上へドラッグして子にする。それを選択して Cmd + D(Windowsは Ctrl + D)を2回押し、3体に増やす(ColumnGuard、ColumnGuard (1)、ColumnGuard (2))。位置は、スクリプトが動かすので、どこでも動きます。ただ、(0, 0, 0) のままだと、シーンビューで入口の広間に衛兵が3体立っている、という紛らわしい絵になります。3体とも、位置を (9, 0, 31.2)(次に置く P0 と同じ場所)にしておくのがおすすめです

  3. MarchColumn の子に、空のオブジェクトを2つ作る。通り道の目印です

  • MarchColumn の位置を (0, 0, 0)、スケールを (1, 1, 1) にしてあるので、子に入れた位置の数字が、そのままシーンの中の座標になります。

  • 衛兵の足が床にめり込んで見えるときは、P0 と P1 のYを、ご自分の通路の床の高さに合わせて、少し上げてください(私の環境なら 0.05)。

  • 目印を3つ、4つと増やせば、途中で曲がる道も作れます。スクリプトは、並べた順にたどるだけです。

現れる場所と消える場所を、プレイヤーから見えない位置にするのがコツです。何もない所に「パッ」と現れるところを見られると、興ざめします。隠れ場所に立ったプレイヤーから、通路の壁に遮られて見えない位置を選びました。P0 は東の壁の向こう、P1 は西の壁の向こうです。舞台の袖と同じです。

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

// 巡回の列。決まった道を、時間差で歩いて、消える。
// 動かし方は、第8弾の導きの精霊と同じ(点から点へ MoveTowards)。
public class MarchColumn : MonoBehaviour
{
    [SerializeField] private Transform[] members;          // 列の一員(先頭から順に)
    [SerializeField] private Transform[] path;             // 通り道(P0=現れる場所 → … → 最後=消える場所)
    [SerializeField] private float speed = 2.0f;           // 歩く速さ
    [SerializeField] private float startInterval = 1.2f;   // 何秒おきに、次の一員が出発するか

    // いま、列が歩いている最中か
    public bool IsMarching { get; private set; }

    private int finished;   // 歩き終えた人数

    private void Awake()
    {
        // 出番まで、全員を隠しておく
        foreach (var m in members) m.gameObject.SetActive(false);
    }

    // 列を出発させる
    public void StartMarch()
    {
        if (IsMarching) return;
        IsMarching = true;
        finished = 0;

        for (int i = 0; i < members.Length; i++)
            StartCoroutine(MarchOne(members[i], i * startInterval));   // i番目は、i × 1.2秒 遅れて出発
    }

    // 一員ぶんの「現れる → 歩く → 消える」
    private IEnumerator MarchOne(Transform member, float delay)
    {
        yield return new WaitForSeconds(delay);

        member.position = path[0].position;      // 現れる場所へ
        member.gameObject.SetActive(true);

        for (int i = 1; i < path.Length; i++)
        {
            Vector3 goal = path[i].position;

            // goal に、ほぼ重なるまで歩く(2センチ以内)
            while ((goal - member.position).sqrMagnitude > 0.02f * 0.02f)
            {
                // 進む向きへ、体をなめらかに向ける
                Vector3 dir = goal - member.position;
                dir.y = 0f;
                if (dir.sqrMagnitude > 0.0001f)
                {
                    Quaternion look = Quaternion.LookRotation(dir);
                    member.rotation = Quaternion.Slerp(member.rotation, look, 6f * Time.deltaTime);
                }

                member.position = Vector3.MoveTowards(member.position, goal, speed * Time.deltaTime);
                yield return null;
            }
        }

        member.gameObject.SetActive(false);      // 消える

        finished++;
        if (finished >= members.Length) IsMarching = false;   // 全員が歩き終えたら、列はおしまい
    }
}

【 コード解説 】

同じコルーチンを、人数ぶん同時に走らせています。 StartCoroutine は、呼ぶたびに新しい流れが1本増えます。MarchOne を3回呼べば、3人ぶんの「待つ → 現れる → 歩く → 消える」が、それぞれ独立して進みます。時間差は、最初の WaitForSeconds(delay) の長さを変えているだけです。一員ごとにスクリプトを付ける必要はありません。

コルーチンを動かしているのは、親の MarchColumn です。 隠れている(非アクティブの)オブジェクトでは、コルーチンを始められません。だから、ずっとアクティブな親が、子を動かします。第10弾前編で、ポーズの見張り役を、隠れるパネル自身ではなく、常にアクティブな空のオブジェクトに載せたのと、同じ理屈です。

MarchColumn を選択して、割り当てます。


6-4. 進行役:SneakEvent

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

// 「列をやり過ごす」イベントの進行役。
// 始まりの合図になる Box Collider(トリガー)に、直接アタッチします。
[RequireComponent(typeof(BoxCollider))]
public class SneakEvent : MonoBehaviour
{
    [SerializeField] private MarchColumn column;
    [SerializeField] private HideSpot hideSpot;
    [SerializeField] private Transform returnAnchor;       // 見つかったときの戻り先
    [SerializeField] private float graceSeconds = 4f;      // 予告から、列が出発するまでの猶予
    [SerializeField] private float watchDelay = 1.5f;      // 出発してから、先頭が分岐に差しかかるまでの時間(この間は見張らない)

    [Header("音(空のままでも動きます)")]
    [SerializeField] private AudioClip warnClip;           // 予告の音
    [SerializeField] private AudioClip foundClip;          // 見つかった音

    private bool started;   // 1回の探索につき、1度だけ

    private void Reset()
    {
        GetComponent<BoxCollider>().isTrigger = true;
    }

    private void OnTriggerEnter(Collider other)
    {
        if (started) return;
        if (other.GetComponent<CharacterController>() == null) return;

        started = true;
        StartCoroutine(Run());
    }

    private IEnumerator Run()
    {
        // ---- 1. 予告する(隠れる時間をあげる) ----
        JudgeFeedbackUI.Instance.ShowMessage("奥から足音が……柱の陰に隠れよう");
        if (warnClip != null) AudioManager.Instance?.PlaySe(warnClip);

        yield return new WaitForSeconds(graceSeconds);

        // ---- 2. 列を出す ----
        column.StartMarch();
        yield return new WaitForSeconds(watchDelay);   // 先頭が見える所へ来るまでは、まだセーフ

        // ---- 3. 列が歩いている間、毎フレーム見張る ----
        bool found = false;
        while (column.IsMarching)
        {
            if (!found && !hideSpot.PlayerInside)
            {
                found = true;   // 以後は見張らない(戻された先で、もう一度見つからないように)

                JudgeFeedbackUI.Instance.ShowMessage("見つかった……! 霧に包まれて戻される");
                if (foundClip != null) AudioManager.Instance?.PlaySe(foundClip);
                MazeDirector.Instance.ReturnPlayer(returnAnchor);   // 第8弾の「霧で戻す」を、そのまま使う
            }
            yield return null;
        }

        // ---- 4. やり過ごせた ----
        if (!found) JudgeFeedbackUI.Instance.ShowMessage("……行ったようだ");
    }
}

【 コード解説 】

  • 新しく書いた仕組みは、ほとんどありません
     
    メッセージは第9弾の ShowMessage、音は第10弾後編の PlaySe、戻すのは第8弾の ReturnPlayer。これまでに作った窓口を、呼んでいるだけです。連載が進むほど、新しい仕掛けが安く作れるようになっていきます。

  • 見張りを始めるのは、列が「見える所」へ来てからです
    列は、壁の向こうから出発します。出発した瞬間に判定を始めると、まだ誰の姿も見えていないのに「見つかった」と言われてしまいます。watchDelay の1.5秒は、先頭が東の壁の向こう(X=9)から、分岐の口(X=6)まで歩く時間です(3メートル ÷ 秒速2メートル)。隠れるための時間は、予告の4秒と合わせて、5.5秒あります。

  • 「予告」が、この仕掛けの生命線です
    第8弾で、迷路を理不尽にしないための条件を考えました。今回も同じです。予告なしで列が現れて、いきなり戻されたら、ただの嫌がらせです。「足音がする」と先に知らせて、隠れる時間(4秒)を渡す。見つかったとしたら、それは自分が間に合わなかったからだ、と思えるようにします。

  • started は、PlayerPrefs に保存しません
     
    第4章の導入演出と違い、こちらは「1回の探索につき1度」です。タイトルへ戻って遊び直せば、また起きます。変数に持っておくだけで、シーンを読み込み直せば自然にリセットされます。

■ シーンに置く
【 始まりの合図 】

  1. 空のオブジェクトを作成。名前を SneakTrigger に

  2. SneakEvent をアタッチ(Box Collider が自動で付き、トリガーになります)

通路の幅いっぱいの、薄い板です。ここを通り抜けた瞬間に、イベントが始まります。第8弾の DepthTrigger と同じ形です。

【 戻り先 】

  1. 空のオブジェクトを作成。名前を SneakReturnAnchor に

  2. 位置 (4, 1, 21)、回転 (0, 0, 0)

割り当て
先ほどの「SneakTriggerオブジェクト」を再選択

📢 列の足音は、まだ付けていません。付けるなら、先頭の衛兵に、第10弾後編の GolemHum と同じ作り方で、3Dのループ音を持たせるのが簡単です。


動作確認

ゲーム開始「▶」で確認します。分岐①を正しく(右へ)抜けて、2本目の直線の通路へ。

  • 通路の中ほどで、「奥から足音が……柱の陰に隠れよう」と出る

  • 柱の陰に入って待つ。 数秒後、先の分岐を、衛兵が3体、間隔を空けて右から左へ横切る。紫の目が、進む方向を向いている

  • 最後の1体が角の向こうへ消えると、「……行ったようだ」と出る。先へ進める

  • もう一度最初から。今度は隠れずに、通路の真ん中に立っている。 先頭の衛兵が分岐に姿を見せるころ、「見つかった……!」と出て、霧に包まれ、通路の手前へ戻される

  • 戻されたあと、もう一度進む。今度は何も起きない(1回の探索につき1度)

  • 衛兵が、プレイヤーを押しのけない。ミニマップに映らない

■ よくあるつまずき


この章のまとめ

  • 決まった道を歩くだけなら、NavMesh Agent は要らない。点から点へ動かすだけでよい

  • 同じコルーチンを人数ぶん走らせ、待ち時間を変えるだけで、時間差の列になる

  • 判定はトリガー1つに割り切った。厳密さより、手応え

  • 予告して、猶予を渡す。 理不尽にしないための、いちばん大事な一手


第7章 仕掛けを開ける(宝箱の開封・視線スイッチから出口の扉へ)

主旨

最後の章では、「開く」ものを2つ作ります。宝箱のフタと、宝物庫の出口の扉です。どちらも、約束の回収です。

  • 第7弾の TreasureChest.Open() に書いたコメント、「演出の作り込みは第11弾で」

  • 第9弾の GazeSwitch に残した📢、「第11弾でここを仕掛けに接続します」

動かす値は、フタの角度と、扉の位置。第3章の型の、5回目と6回目です。


7-1. 宝箱のフタに、蝶番(ちょうつがい)を付ける

いまの Open() は、フタを消して、秘宝を出すだけです。

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

これを、「フタがゆっくり持ち上がり、中から秘宝が現れる」に変えます。

■ 目玉つまずき:そのまま回すと、フタがプロペラになる
フタ(Lid)の角度を動かせばいい——と思って、試しにインスペクターで Lid の回転のXを動かしてみてください。フタは、その場で、真ん中を軸にしてくるりと回ります。 箱のフタというより、プロペラか、回転ドアです。(確かめたら、Cmd + Z(Windowsは Ctrl + Z)で、回転を元の 0 に戻しておいてください)

オブジェクトは、自分の原点を軸に回る。 Unityのキューブの原点は、真ん中にあります。そして、プリミティブの原点の位置は、動かせません。

フタを「奥の辺」を軸にして開けたいなら、奥の辺の位置に、空のオブジェクトを置いて、フタをその子にします。 回すのは、空の親のほうです。親が回ると、子は親の原点を中心に、腕を振るように回ります。これが蝶番になります。

■ Prefab モードで作業する

宝箱のペアは、第8弾でPrefabにして、3か所の分岐に置きました。シーンの中の3つを1つずつ直すのではなく、Prefabの本体を直します。 第10弾後編で、区画のPrefabにミニマップ用の板を仕込んだときと、同じ手順です。

  1. プロジェクトウィンドウで Assets/Prefabs の ChestPair をダブルクリック(Prefab モードに入ります。背景が青っぽくなります)

  2. 先に、箱の正面がどちら側かを調べる。 ChestA の子の EyeL を選択して、位置のZがマイナスかプラスかを見る(すぐ下の表で使います)

  3. ヒエラルキーで ChestA を右クリック ▸ 空のオブジェクトを作成。名前を LidHinge に

  4. LidHinge の位置を、下の表のとおりに設定。回転 (0, 0, 0)、スケール (1, 1, 1)

  5. ヒエラルキーで、Lid を LidHinge の上へドラッグして、子にする

  6. ChestB にも、同じことをする(3〜5)

  7. ヒエラルキーの左上の < を押して、Prefab モードを抜ける(シーンビューの右上の「自動保存」がオンなら、変更は自動で保存されます。オフの場合は、その隣の「保存」を押してから抜けます)

蝶番をスクリプトに教える作業は、次の節でスクリプトを直したあとに行います。

LidHinge の位置は、目がどちら側に付いているか(手順2)で変わります。

蝶番は、正面の反対側(奥)の、箱の上の角に置きます。Yの0.6は、箱(Body)の天面の高さ。Zの0.275は、フタの奥行き(0.55)の半分です。

💡 手順5で Lid を子にすると、インスペクターの Lid の位置の数字が、(0, 0.66, 0) から (0, 0.06, −0.275) のように変わります。壊れたわけではありません。 位置の数字は「親から見た位置」なので、親が変われば数字も変わります。見た目の場所は、1ミリも動いていません。

  • LidHinge のスケールは、必ず (1, 1, 1) のままにします。Lid は、(0.85, 0.12, 0.55) という、縦横で違う拡大率を持っています。ここまでは問題ありません。ですが、親のほうが縦横で違う拡大率を持っていると、子を回したときに、子が斜めに歪みます。第7弾で宝箱を組んだとき、スクリプトを付ける親(ChestA・ChestB)を、スケール1の空のオブジェクトにしたのは、このためです

  • シーンに置いた3つの宝箱のペアは、分岐ごとの設定(分岐の名前、秘宝の番号、戻り先)を、それぞれ上書きで持っています。Prefabの本体に部品を足しても、その上書きは消えません。


7-2. TreasureChest を改修する

TreasureChest.cs の、改修後の全体です。変わったのは4か所です。

// ==========================================================
// 📄 スクリプト:TreasureChest.cs(第11弾前編・改修後・全体)
// ==========================================================
using System.Collections; // 💡 第11弾で追加:コルーチン(IEnumerator)を使うため
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;

    // 💡 第11弾で追加:フタの開き方
    [Header("フタの開き方(第11弾)")]
    [SerializeField] private Transform lidHinge;        // フタの蝶番(空の親)
    [SerializeField] private float openAngle = 100f;    // 開く角度。逆に開くなら -100 に
    [SerializeField] private float openSeconds = 0.9f;  // フタが開ききるまでの秒数
    [SerializeField] private float popSeconds = 0.35f;  // 秘宝が現れるまでの秒数

    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);         // フタを閉じる
        if (lidHinge != null) lidHinge.localRotation = Quaternion.identity; // 💡 第11弾で追加:蝶番も閉じた角度へ
        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弾で変更:一瞬で「開いた状態」にする。
    //    前の周で取った秘宝を、ゲーム開始時に復元するとき用(第10弾前編の RestoreAsSolved)。
    //    ゲームが始まった瞬間にフタがギギギ……と開いたらおかしいので、こちらは演出なし。
    public void Open()
    {
        if (lidHinge != null)
            lidHinge.localRotation = Quaternion.Euler(openAngle, 0f, 0f); // フタを、開ききった角度に
        else
            lid.SetActive(false); // 蝶番が未設定なら、これまでどおりフタを消す

        treasure.SetActive(true);
    }

    // 💡 第11弾で追加:演出つきで開ける。見抜いたとき用(第7弾の約束の回収)
    public IEnumerator OpenRoutine()
    {
        if (lidHinge == null) { Open(); yield break; } // 蝶番が未設定なら、一瞬で開けて終わり

        // ---- フタを開ける:動かす値は、蝶番の角度 ----
        Quaternion from = lidHinge.localRotation;
        Quaternion to = Quaternion.Euler(openAngle, 0f, 0f);

        float time = 0f;
        while (time < openSeconds)
        {
            time += Time.deltaTime;
            float t = Mathf.SmoothStep(0f, 1f, time / openSeconds); // ゆっくり動き出し、ゆっくり止まる
            lidHinge.localRotation = Quaternion.Slerp(from, to, t);
            yield return null;
        }
        lidHinge.localRotation = to;

        // ---- 秘宝が現れる:動かす値は、大きさ ----
        Transform tr = treasure.transform;
        Vector3 full = tr.localScale;   // 本来の大きさを覚えておく
        tr.localScale = Vector3.zero;   // いったん、大きさ0に
        treasure.SetActive(true);

        time = 0f;
        while (time < popSeconds)
        {
            time += Time.deltaTime;
            tr.localScale = Vector3.Lerp(Vector3.zero, full, time / popSeconds);
            yield return null;
        }
        tr.localScale = full;
    }

    // 💡 追加:この箱を「調べられるもの」にするか、外すか
    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;
        }
    }
}

■ 蝶番を割り当てる
スクリプトを保存すると、Treasure Chest のインスペクターに、新しい欄が増えます。もう一度 Prefab モードに入って、割り当てます。

  1. Assets/Prefabs の ChestPair をダブルクリック

  2. ChestA を選択 ▸ Treasure Chest の Lid Hinge 欄に、ChestA の下の LidHinge をドラッグ

  3. 目(EyeL(または EyeR)の位置Zがプラス側だった方は、Open Angle を −100 に

  4. ChestB も同じ(ChestB の下の LidHinge を入れます。取り違えないように)

  5. < で Prefab モードを抜ける

■ コードの解説

  1. Open() を2つに分けた理由
    Open() を呼んでいる場所は、2つあります。宝箱を見抜いたときと、第10弾前編で足した「前の周で取った秘宝を、ゲーム開始時に開いた状態で復元する」ときです。後者で演出が流れると、Mainシーンが始まった瞬間に、3つの宝箱のフタが一斉に開き始めます。同じ「開ける」でも、見せたいときと、見せたくないときがある。 だから、一瞬で開く版(Open)と、演出つきの版(OpenRoutine)に分けました。名前を変えていないので、復元の側のコードには手を入れなくて済みます。

  2. 角度は Quaternion.Slerp で動かします
    位置や大きさは Lerp で、回転は Slerp。回転を Lerp の足し算で混ぜると、途中で変な方向へねじれることがあるためです。「回転をなめらかに動かすなら Slerp」と覚えておけば十分です。

  3. フタは、もう消しません
     
    開いたフタが、箱の後ろに立ったまま残ります。中を開けた、という手応えが出ます。


7-3. ChestPairController を改修する(表示の不具合も、ここで直します)

■ お詫びと訂正:結果の表示が、ずっと崩れていました

第8弾で宝箱を分岐に置いたとき、ChestPairController の結果の表示を、次のように書きました。

JudgeFeedbackUI.Instance.Show(true,  "あたり!");     // 正解のとき
JudgeFeedbackUI.Instance.Show(false, "はずれ……");   // 不正解のとき

ところが、受け取る側の Show は、第7弾でこう書いています。

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

    // ……(このあと、フェードの表示を始める処理が続きます)……
}

2つ目の引数は、「見分けポイントの名前」を渡すための場所でした。そこへ「あたり!」を渡したので、画面には、こう出ていたのです。

“せいかい! (見分けポイント:あたり!)”

第8弾から第10弾後編まで、4本の記事にわたって、このままでした。「外したときの学びになるように」と自分で書いておきながら、その学びを消していました。お詫びします。

直し方は簡単です。ChestPairController は、いま付いている見分けポイントを currentTell という変数に覚えています。第7弾のScriptableObjectには、名前を入れる tellName があります。それを渡すだけです。

■ 改修後の全体
ChestPairController.cs の、改修後の全体です。変わったのは4か所です。

// ==========================================================
// 📄 スクリプト:ChestPairController.cs(第11弾前編・改修後・全体)
// ==========================================================
using System.Collections; // 💡 第11弾で追加:コルーチン(IEnumerator)を使うため
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;
    // 💡 追加:この分岐の秘宝が使う「電球の番号」(J1=0, J2=1, J3=2)
    [SerializeField] private int treasureIndex;
    // 不正解時にプレイヤーを戻すアンカー
    [SerializeField] private Transform returnAnchor;
    // 基準の銘板文字列
    [SerializeField] private string basePlateText = "めざめのたから";

    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip correctClip; // 開錠
    [SerializeField] private AudioClip wrongClip;   // 失敗

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

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


    // 💡 追加:前の周で解決済みだった姿(開いたランドマーク)を再現する
    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); // 司令塔の帳簿にも記帳
    }

    // どちらかの箱が開けられたときに呼ばれる判定
    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();
            StartCoroutine(OpenSequence(picked)); // 💡 第11弾で変更:演出つきで開ける(その間、番人を止める)
            
            AudioManager.Instance?.PlaySeAt(correctClip, picked.transform.position); // 🔊 追加:開けた箱の位置で
            
            // この分岐を解決済みにする
            IsSolved = true;
            // 秘宝を1つ増やす
            // 変更前:GameManager.Instance.AddTreasure();
            GameManager.Instance.CollectTreasure(treasureIndex); // 💡 差し替え
            // 司令塔にこの分岐の解決を記録する
            MazeDirector.Instance.MarkSolved(junctionId);
            // 両方の箱を「調べられるもの」から卒業させる
            chestA.SetExaminable(false);
            chestB.SetExaminable(false);
            // 進捗を通知して精霊タイマーを戻す
            MazeDirector.Instance.NotifyProgress();
            // 成功フィードバックを表示する
            // 変更前:JudgeFeedbackUI.Instance.Show(true, "あたり!");
            JudgeFeedbackUI.Instance.Show(true, TellLabel()); // 💡 第11弾で修正:見分けポイントの名前を渡す
        }
        else
        {
            AudioManager.Instance?.PlaySeAt(wrongClip, picked.transform.position); // 🔊 追加
            // 失敗回数を増やす
            GameManager.Instance.AddMistake();
            // 失敗フィードバックを表示する
            // 変更前:JudgeFeedbackUI.Instance.Show(false, "はずれ……");
            JudgeFeedbackUI.Instance.Show(false, TellLabel()); // 💡 第11弾で修正
            // プレイヤーをやさしく手前へ戻す
            MazeDirector.Instance.ReturnPlayer(returnAnchor);
        }
    }

    // 💡 第11弾で追加:結果表示に出す「見分けポイントの名前」(第7弾のSOの tellName)
    private string TellLabel() => currentTell != null ? currentTell.tellName : "―";

    // 💡 第11弾で追加:開封の演出。フタが開いている間だけ、番人を止める
    private IEnumerator OpenSequence(TreasureChest picked)
    {
        StageGate.IsPlaying = true;         // 演出の旗を立てる(第2章)
        yield return picked.OpenRoutine();  // フタが開き、秘宝が現れるまで待つ
        StageGate.IsPlaying = false;        // 旗を下ろす
    }

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

■ コードの解説

  • OpenSequence は、フタが開いている間だけ、番人を止めます。 分岐③の宝箱は、番人の巡回路のすぐ脇にあります。フタが開くのを眺めている1秒ちょっとの間に、背後から捕まったら台無しです。第2章で作った旗の、最初の実戦投入です。

  • RestoreAsSolved は、触っていません。 前の周で取った秘宝を復元するところでは、これまでどおり Open() を呼んでいます。Open() のほうを「一瞬で開く版」に作り替えたので、呼ぶ側は何も変えずに済みました。

  • 秘宝の記録や、調べられなくする処理は、フタが開くのを待たずに進みます。 StartCoroutine は、演出を「始めて」、すぐ次の行へ進むからです。演出の途中でゲームを終了されても、秘宝はもう保存されています。


7-4. 視線スイッチに、出口を足す

第9弾で作った、見つめると灯るクリスタル。灯ったあと、何も起きませんでした。GazeSwitch は、自分が灯ったことを、誰にも知らせていないからです。知らせる口を足します。

■ 「クリック時 ()」の正体:UnityEvent
第10弾前編で、タイトル画面のボタンを、インスペクターの「クリック時 ()」の欄で結線しました。「+」を押して、オブジェクトをドラッグして、呼びたいメソッドを選ぶ、あの欄です。

あの欄の正体は、UnityEvent という仕組みです。そしてこれは、ボタン専用ではありません。自分のスクリプトにも、同じ欄を作れます。

GazeSwitch.cs の、改修後の全体です。変わったのは3か所だけです。

// ==========================================================
// 📄 スクリプト:GazeSwitch.cs(第11弾前編・改修後・全体)
// ==========================================================
using UnityEngine;
using UnityEngine.Events; // 💡 第11弾で追加:UnityEvent を使うため

// プレイヤーが一定時間見つめると点灯するクリスタル(第9弾)。
// 第10弾後編:見つめている間の計数音と、点灯音を追加。
// 第11弾前編:点灯したことを外へ知らせる口(UnityEvent)を追加。
public class GazeSwitch : MonoBehaviour
{
    [SerializeField] private Renderer crystalRenderer;
    [SerializeField] private Material crystalOff;
    [SerializeField] private Material crystalOn;
    [SerializeField] private float gazeAngle = 30f;
    [SerializeField] private float gazeDistance = 8f;
    [SerializeField] private float gazeRequired = 1.2f;

    // 💡 追加:効果音
    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip tickClip;              // 計数音(見つめている間)
    [SerializeField] private AudioClip litClip;               // 点灯音
    [SerializeField] private float tickIntervalStart = 0.35f; // 見つめ始めの間隔(秒)
    [SerializeField] private float tickIntervalEnd = 0.08f;   // 点灯直前の間隔(秒)

    // 💡 第11弾で追加:点灯した瞬間に呼ぶ相手。インスペクターで登録する(ボタンの「クリック時 ()」と同じ仕組み)
    [Header("点灯したときに呼ぶもの(第11弾)")]
    [SerializeField] private UnityEvent onLit;

    private LayerMask obstructionMask;
    private Camera cam;
    private float gazeTime = 0f;
    private bool isOn = false;
    private float tickTimer = 0f; // 💡 追加:次のティックまでの計測

    void Start()
    {
        cam = Camera.main;
        obstructionMask = LayerMask.GetMask("Default");
        ApplyMaterial();
    }

    void Update()
    {
        if (isOn) return;
        if (cam == null) return;

        Vector3 toCrystal = transform.position - cam.transform.position;
        bool near = toCrystal.sqrMagnitude <= gazeDistance * gazeDistance;
        bool facing = Vector3.Angle(cam.transform.forward, toCrystal) < gazeAngle;
        bool clear = !Physics.Linecast(
            cam.transform.position, transform.position,
            obstructionMask, QueryTriggerInteraction.Ignore);

        if (near && facing && clear)
        {
            gazeTime += Time.deltaTime;

            // 💡 計数音:進み具合(0→1)に応じて、鳴らす間隔を Start → End へ縮める
            float progress = gazeTime / gazeRequired;
            float interval = Mathf.Lerp(tickIntervalStart, tickIntervalEnd, progress);
            tickTimer += Time.deltaTime;
            if (tickTimer >= interval)
            {
                tickTimer = 0f;
                AudioManager.Instance?.PlaySeAt(tickClip, transform.position);
            }

            if (gazeTime >= gazeRequired)
            {
                isOn = true;
                ApplyMaterial();
                AudioManager.Instance?.PlaySeAt(litClip, transform.position); // 💡 📢→🔊 点灯音
                // 📢→✅ 第11弾で回収:登録された相手を、全部呼ぶ
                onLit?.Invoke();
            }
        }
        else
        {
            // 見るのをやめたら、未点灯の進捗は少しずつ戻る。ティックも仕切り直し
            gazeTime = Mathf.MoveTowards(gazeTime, 0f, Time.deltaTime);
            tickTimer = 0f; // 💡 追加
        }
    }

    private void ApplyMaterial()
    {
        if (crystalRenderer == null) return;
        crystalRenderer.sharedMaterial = isOn ? crystalOn : crystalOff;
    }
}

保存してUnityに戻ると、クリスタル(GazeSwitch を付けたオブジェクト)のインスペクターに、ボタンと同じ見た目の On Lit () という欄が増えています。

GazeSwitch は、扉のことを何も知りません。 ここが大事なところです。GazeSwitch のコードには、「扉」という言葉が一度も出てきません。「灯ったら、誰かを呼ぶ」としか書いていない。だから同じクリスタルを、扉にも、隠し階段にも、明かりにも、コードを変えずに繋げます。第5弾の IInteractable で、「調べる側は、相手が石碑か宝箱かを知らなくていい」としたのと、同じ考え方です。

後編では、もう1つの知らせ方、C# の event を使います。違いを先にまとめておきます(上記の表)。


7-5. 出口の扉を作る

■ 扉を置く場所は、入口ではなく、出口

最初、私は「クリスタルを灯すと、宝物庫の入口の扉が開く」にしようとしました。ですが、配置を確かめて、無理だと気づきました。第9弾でクリスタルを置いたのは、(−1, 1.3, 59)。宝物庫の中です。通路の側から見ると、入口の脇の塞ぎ板が視線を遮るので、中に入らないと見つめられません。入口の扉を開ける鍵が、扉の内側にある、という状態です。

そこで、扉は出口の手前に置きます。宝物庫の奥を壁で仕切って小部屋にし、そこへ第10弾前編のエンディングの入口(EndingTrigger)を閉じ込めます。

■ 仕切りの壁

第6章の柱と同じ手順(ゲームオブジェクト ▸ ProBuilder ▸ キューブ → インスペクターの Pro Builder Shapeの サイズ)で、2枚作ります。

宝物庫の幅は12メートル(Xが−2から10)。左右から5メートルずつ塞ぐと、真ん中に幅2メートルの口(Xが3から5)が残ります。

■ 扉
扉は、第7弾の宝箱や第9弾の番人と同じ、「空の親 + 見た目の子」で作ります。

  1. ヒエラルキーで 空のオブジェクトを作成。名前を ExitDoor に。位置 (4, 2.5, 66)、スケール (1, 1, 1)

  2. ExitDoor を右クリック ▸ 3D オブジェクト ▸ キューブ。名前を DoorSlab に。位置 (0, 0, 0)、スケール (2, 5, 0.3)

  3. Assets/Materials を右クリック ▸ 作成 ▸ マテリアル。名前を ExitDoorMat に。シェーダーは作ったときのまま(URPのLit)で、ベースマップの色だけを R 70/G 60/B 50 に。できたら、DoorSlab へドラッグして割り当てる

DoorSlab の Box Collider は、残します。 これが、プレイヤーを通さない壁になります。

■ 扉に、手がかりを刻む

このままだと、初めて遊ぶ人は、扉の前で立ち尽くします。クリスタルを見つめればいい、と気づく手がかりが、どこにもないからです。第8弾で決めた「理不尽にしない」のために、扉に一言、刻みます。第6弾の石碑と同じ、3Dの文字です。

  1. ExitDoor を右クリック ▸ 3D オブジェクト ▸ テキスト - TextMeshPro。名前を DoorText に

  2. 次のように設定(TextMeshProのインスペクターは日本語化されないので、項目名は英語のまま書きます。第6弾の石碑と同じ画面です)

位置のYの −0.9 は、目の高さに合わせるためです。扉の中心は床から2.5メートルの高さにあるので、そこから0.9下げると、床から1.6メートル。文字は、見上げなくても読める高さに刻みます。Zの −0.16 は、扉の板の表面(厚さ0.3の半分=0.15)の、すぐ手前です。

⚠️ DoorText を、DoorSlab の子にしてはいけません。 DoorSlab は (2, 5, 0.3) という、縦横で違う拡大率を持っています。その子にした文字は、縦に2.5倍、引き伸ばされます。文字は、スケール1の親(ExitDoor)の子にします。第6弾の石碑は、キューブの子に文字を置く形で作ったので、同じ問題を抱えやすい作りになっています(第12弾で見た目を差し替えるときに、整え直します)。今回は、最初から素直な形で作ります。

■ スクリプト:ExitDoor.cs

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

// 出口の扉。Open() が呼ばれると、床の下へ沈んで、道を開ける。
// 空の親(ExitDoor)にアタッチします。親が動けば、板も文字も一緒に動きます。
public class ExitDoor : MonoBehaviour
{
    [SerializeField] private Vector3 openOffset = new Vector3(0f, -5.2f, 0f);   // 開いたとき、どれだけ動くか(下へ5.2m)
    [SerializeField] private float openSeconds = 2.5f;                          // 開ききるまでの秒数
    [SerializeField] private AudioClip openClip;                                // 開く音(空でも動きます)

    private bool opened;

    // 扉を開ける。GazeSwitch の「On Lit ()」から呼ばれる
    public void Open()
    {
        if (opened) return;   // 二度は開けない
        opened = true;
        StartCoroutine(OpenRoutine());
    }

    private IEnumerator OpenRoutine()
    {
        JudgeFeedbackUI.Instance.ShowMessage("奥で、重い扉の動く音がした");
        if (openClip != null) AudioManager.Instance?.PlaySeAt(openClip, transform.position);   // 第10弾後編:その場所で鳴らす

        // 動かす値は、扉の位置
        Vector3 from = transform.position;
        Vector3 to   = from + openOffset;

        float time = 0f;
        while (time < openSeconds)
        {
            time += Time.deltaTime;                                  // 経過時間を足す
            float t = Mathf.SmoothStep(0f, 1f, time / openSeconds);  // 0 → 1 の進み具合(ゆっくり始まり、ゆっくり止まる)
            transform.position = Vector3.Lerp(from, to, t);          // ★値を入れる先:扉の位置
            yield return null;
        }
        transform.position = to; // 誤差をなくすため最後にきっちり
    }

    [ContextMenu("扉テスト")]
    private void TestOpen() => Open();
}

扉は、床の下へ沈みます。コライダーも板と一緒に沈むので、「当たり判定を切る」処理は要りません。床の下へ行ってしまえば、もう誰の邪魔もしません。

■ アタッチ
ExitDoor にアタッチしてください。Open Clip は空で構いません。手持ちの効果音から、重い物が動く音を選んで入れると、ぐっと良くなります。

💡 扉の動きだけを先に確かめたいときは、再生中に ExitDoor を選択し、インスペクターの Exit Door の見出しを右クリック(または右端の ⋮)▸ 扉テスト。クリスタルを見つめなくても、扉が沈みます。沈む深さ(Open Offset)や速さ(Open Seconds)の調整に使えます。

■ 繋ぐ

いよいよ、クリスタルと扉を繋ぎます。コードは1行も書きません。

  1. ヒエラルキーで、第9弾の Crystal(GazeSwitch を付けたオブジェクト)を選択

  2. インスペクターの Gaze Switch の On Lit () の欄で、右下の + をクリック

  3. 行が1つ増える。左下の「なし (オブジェクト)」の欄へ、ヒエラルキーから ExitDoor をドラッグ

  4. 右側の「No Function」のドロップダウンをクリック ▸ ExitDoor ▸ Open ()

第10弾前編で、ボタンの「クリック時 ()」に OnClickStart を繋いだのと、まったく同じ操作です。

⚠️ ドロップダウンに Open () が出てこないときは、ExitDoor.cs の Open が public になっているかを確かめてください。private のメソッドは、この一覧に出ません。


7-6. 仕上げ:NavMesh を焼き直す

この記事で、遺跡に動かない物を3つ足しました。第6章の柱(Pillar_C)と、仕切りの壁が2枚です。第9弾で柱を立てたときと同じく、番人の歩ける範囲(NavMesh)を焼き直しておきます。焼き直さないと、番人が追跡でここまで来たとき、柱や壁をすり抜けます。

焼く前に、ひと手間かけます。

  1. ヒエラルキーで、GolemRoot と、MarchColumn の下の衛兵3体のチェックを外して、一時的に非アクティブにする(インスペクター上からでも可)

  2. NavMeshRoot を選択 ▸ インスペクターの NavMesh Surface の ベイク(Bake)をクリック

  3. シーンビューで、水色の面が、柱と壁をよけて張られていることを確認

  4. GolemRoot と衛兵3体のチェックを、元に戻す

🧩 なぜ、非アクティブにするのか。 第9弾で、NavMesh の材料を「シーンの中の、描画されるもの全部」にしました。すると、番人や衛兵の体も、「そこにある障害物」として焼き込まれるおそれがあります。番人がふだん立っている場所に、番人の形の穴が開いた地図、というわけです。焼く間だけ席を外してもらえば、この心配はなくなります。

  • ミニマップには、床の板だけを映しています(第10弾後編)。そのため、今回足した柱・仕切りの壁・扉は、地図には出ません。宝物庫は、これまでどおり1つの部屋として映ります。

  • 扉は閉じた状態で焼かれるので、番人の地図では、出口の小部屋は「行けない場所」になります。番人がそこへ行く用事は無いので、これで構いません。


総合動作確認

最後は、Bootシーンから通して遊びます。導入をもう一度見たい方は、先に第4章の「導入演出のフラグを消す」を実行しておいてください。

■ よくあるつまずき


この章のまとめ

  • オブジェクトは、自分の原点を軸に回る。軸を変えたいなら、軸の位置に空の親を置く

  • 同じ「開ける」でも、見せたいときと、見せたくないときがある。一瞬の版と、演出の版に分ける

  • UnityEvent で、自分のスクリプトにも「クリック時 ()」と同じ欄を作れる。呼ぶ側は、相手のことを知らなくていい

  • 縦横で拡大率が違うオブジェクトを、親にしない


おわりに

今回、実装したもの

お疲れさまでした。通して遊んでみてください。始まり方が変わり、宝箱の手応えが変わり、宝物庫の奥に、もう一仕事が増えました。やっていることは同じ「宝探し」なのに、ずいぶん印象が違うはずです。


今回の学び

新しい機能は、ほとんど使いませんでした。それでも、これだけのことができました。振り返ると、繰り返し出てきた考え方が5つあります。

  • 演出の多くは、「値を、時間をかけて、AからBへ動かす」でできている。 透明度、まぶた、音量、カメラの角度、フタの角度、扉の位置。同じ形のコルーチンを、6回書きました。行って戻ってを繰り返す動き(精霊のゆらぎ)だけは、サイン波でした。

  • 動かしたいものには、「係」がいる。外からいじらず、係に頼む。 アバターの係は PlayerLocomotion、カメラの係は Cinemachine でした。係を無視して直接動かすと、上書きし合って、滑ったり戻されたりします。

  • 止めたもの・変えたものは、必ず戻る構造にする。 演出の旗も、全体の音量も、関所で戻します。演出には、時間切れの保険を付けます。「終わらない演出」と「戻らない値」は、原因が見つけにくい、いちばん厄介な不具合です。

  • 動かすのは本体、揺らすのは見た目。縦横で拡大率が違うものを、親にしない。 第7弾から続けてきた「空の親 + 見た目の子」の形が、蝶番でも、精霊でも、扉の文字でも効きました。

  • 予告して、猶予を渡す。 巡回の列も、扉の文字も、「知らせてから、試す」にしました。第8弾の「理不尽にしない」の続きです。


後編予告

前編で、遺跡は動き出しました。ですが、結末は、まだ1種類しかありません。第10弾前編で EndingType を作ったとき、「3種類に増えるのは第11弾」と書きました。LastEnding という伝言板も、書き込むだけで、まだ誰も読んでいません。

後編では、次のものを作ります。

  • 手がかりを、データで持つ。 第7弾の ScriptableObject の、2回目の出番です

  • 集めて、保存して、知らせる。 今回の UnityEvent の兄弟、C# の event を使います

  • 推理ボード。 集めた手がかりを空欄にはめて、遺跡の秘密を言い当てます。UIの部品が一番多い章になります

  • 3つの結末。 見抜いた数で、選べる道が変わります。今回作った「静けさ」は、ここで初めて使います

  • 次の周回に残るもの。 LastEnding を、ついに読み出します。今回の「一度きりの印」も、もう一度使います

連載の演出を、最後までコルーチンで書き通してきました。Unityには、演出専用の Timeline という道具もあります。なぜそちらを使わなかったのか、その答え合わせも、後編の最後に書くつもりです。

それでは、後編でお会いしましょう。

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

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