メインコンテンツへスキップ
見出し画像

【Unity】番人ゴーレムを実装する(NavMesh巡回・視界判定・ランタンステルス)

    第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弾:『ゲームの骨格 — シーン・セーブ・音』

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

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

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

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

    前回の第8弾では「迷いの回廊」を作りました。ぐるりとつながったループ回廊、シームレスにワープするテレポート、そしてプレイヤーを追いかけてくる導きの精霊。あの精霊はNavMeshを使わず、MoveTowardsで直線的にプレイヤーへ寄っていくだけの、とてもシンプルな作りでした。

    ■ 番人ゴーレム

    画像

    第8弾の末尾で、こんな予告をしていました。「次回は迷路の上にNavMeshをベイクして、壁を賢く避けて巡回する番人を作ります。壁越しに見えてしまう問題は遮蔽判定で解決し、ランタンはただの灯りからステルスの鍵へ昇華させます」と。今回はその約束を回収する回です。導きの精霊はNavMesh不使用、番人はNavMesh使用──この対比が、今回の主役になります。

    ■ ランタンのトレードオフ

    画像

    そして第5弾で「今後の布石」として公開しておいた LanternController.IsOn。あれをついに使います。ランタンを点ければ遠くから見つかる。

    画像

    逆に、消せば近づけるけれど自分の視界も暗くなる。灯りが「探索の道具」から「命綱でもあり枷でもあるもの」に変わる瞬間です。


    ■ 今回の到達点

    画像
    今回の到達点(全10項目)
    1. AI Navigationで中空迷路の上にNavMeshをベイクできる

    2. NavMeshAgentのゴーレムがウェイポイントを巡回する

    3. 視野角+距離+遮蔽Raycastの三段判定で発見される

    4. ランタン点灯で見つかりやすく、消灯で隠密(自分の視界も暗い)というトレードオフ

    5. 状態機械(巡回/警戒/追跡/見失い)で番人が振る舞う

    6. 物陰と消灯で見失わせ、目を盗んで宝物庫に忍び込める

    7. 捕まると霧に包まれて手前へ戻される(第8弾の仕組みを流用)

    8. 検知ゲージと頭上マークで警戒度が見える

    9. 見つめると灯る視線スイッチ

    10. 第5弾の宿題「壁越しに調べられる問題」を解決

    👩‍💻 こんな方に向けた内容です
    ・NavMeshを現行方式(AI Navigation 2.x)で使いたい方
    ・ネットの古いNavigation記事(Navigation Staticでベイクする手順)と画面が違って詰まった方
    ・敵AI / 視界判定 / ステルスの基礎をゼロから作りたい方
    ・第1〜8弾から続けて作っている方

    🛠️ 動作検証環境
    ・macOS Tahoe 26.5.1
    ・Unity 6.5(6000.5.5f1)+URP 17.5.0
    ・Input System 1.20.0(自作GameControls+Generate C# Class方式)
    ・Cinemachine 3.1.7
    ・UniVRM 0.131.2(VRM0.xのVRoidアバター model_test)
    ・ProBuilder 6.1.2
    ・Unity Localization 1.5.12
    ・AI Navigation 2.0.14(本記事で導入。公式マニュアルの「Unity Editor 6000.5向けにパッケージバージョン2.0.14がリリース」記載に基づく)
    ・エディタは日本語化(メニュー名は日本語表記+必要に応じ英語併記)

    ※ バージョンや環境によってメニュー名・既定値・座標は変わります。本連載では座標・回転は「環境により読み替え」が作法です。数値はすべて筆者環境での実測・目安としてお読みください。


    本記事の構成

    画像
    ※ 第1章は「はじめに」なので除く
    • 第2章 NavMeshをベイクする — 迷路が「歩ける地面」になる(現行方式と旧方式の違いを整理し、中空メッシュに床だけNavMeshを焼く)

    • 第3章 番人ゴーレムを組み立てて巡回させる(見た目・NavMeshAgent・巡回の骨組み)

    • 第4章 視界判定と遮蔽 — 三段のフィルターで「見える」を作る(距離→角度→遮蔽)

    • 第5章 状態機械とランタンステルス — 番人に「心」を入れる(FSM完成+IsOn回収+捕獲)

    • 第6章 検知ゲージと頭上マーク — 警戒を「見える化」する(UI構築の丁寧解説)

    • 第7章 視線スイッチと、第5弾の宿題回収(内積判定の再利用+Interactor遮蔽)

    ステップ・バイ・ステップで、番人ゴーレムとステルス要素を完成させます。順番に進めることで、仕組みを確実に理解できる構成になっています。


    第2章 NavMeshをベイクする — 迷路が「歩ける地面」になる

    NavMeshとは

    そもそも、Unityで賢い敵キャラクターを作るにはどうすればよいのでしょうか。 プログラムで単に「プレイヤーの方向へ進め」と命令するだけでは、AIは空間の構造を理解していないため、壁に激突してそのまま引っかかってしまいます。これを解決するために、Unityには「地図(NavMesh)」と「賢い運転手(NavMeshAgent)」のセットが用意されています。

    画像
    Unityで賢い敵キャラクターを作るには

    1つ目は、NavMesh(ナビメッシュ)です。 これはAIが空間を理解するための「目に見えない歩行可能エリアの地図」です。シーン内の床や壁、障害物の配置をUnityに解析させ、「ここは歩ける」「ここは通れない」というデータ(網目)を生成して焼き付けます。この作業を「ベイク(Bake)」と呼びます。

    2つ目は、その上を歩くAIである NavMeshAgent(ナビメッシュエージェント)です。 キャラクターの頭脳となる専用コンポーネントで、ベイクされたNavMeshの地図を読み取る能力を持っています。私たちが「あの場所へ行って」と目的地を指示するだけで、このAgentが「壁を避ける最短ルート」を瞬時に自動計算し、カーブを曲がりながらキャラクターを目的地まで移動させてくれるのです。

    「ベイクして地図を作り、Agentに歩かせる」これが今回の実装の根幹になります。

    では、さっそくAI Navigationパッケージの導入からしていきましょう。


    AI Navigationパッケージを導入する

    第2弾でパッケージマネージャーの使い方はやりましたね。同じ話法で進めます。

    画像
    • メニューの「ウィンドウ」▸「Package Management」▸「パッケージマネージャー」を開きます。

    • 左側のパッケージ一覧の上にある絞り込みを「Unityレジストリ(Unity Registry)」に切り替えます。

    • 検索欄に「AI Navigation」と入力します。

    • 出てきた「AI Navigation」を選び、右下の「インストール(Install)」を押します。


    NavMeshRootを作ってNavMesh Surfaceを付ける

    画像
    • ヒエラルキーの何もないところを右クリック ▸「空のオブジェクトを作成(Create Empty)」。

    • 名前を NavMeshRoot に変更します(名称は任意)。

    • Transformの位置を (0, 0, 0) にリセットしておきます。

    • インスペクターの「コンポーネントを追加」▸「Navigation」▸「NavMesh Surface」を選びます。

    次のように設定します

    ・Agent Type:Humanoid(既定のまま)。
    ・Default Area:Walkable(既定のまま)。
    ・Generate Links:オフ(今回はリンク不要)。
    ・Use Geometry:Render Meshes を選びます。
    ・Collect Objects:All Game Objects(既定のまま)。

    ■ Render Meshes

    画像

    Use Geometry を Render Meshes(レンダーメッシュとTerrain)にするのがポイントです。もう一方の Physics Colliders(コライダーとTerrain)にすると、迷路内に置いた透明トリガー(Is Triggerのコライダー群)まで拾ってしまうおそれがあります。ProBuilderで作った迷路は、見た目のメッシュ=レンダーメッシュを素直に持っているので、Render Meshesが素直に効きます。


    エージェント寸法の考え方

    Navigationウィンドウ(Window > AI > ナビゲーション)の「Agentsタブ」で、Humanoidの寸法を確認・調整できます。今回はおおむね次の値を目安にします。

    • Agent Radius:0.5(通路が断面4×3あるので、半径0.5なら余裕を持ってすれ違えます)

    • Agent Height:2.0(身長2m級のゴーレムが通れる高さ。⚠️ここを内寸3mより大きくすると「天井が低くて通れない」と判定され焼けません)

    • Max Slope:45(既定。床は水平なので問題なし。公式スクリプトリファレンスによると有効範囲は0〜60度で、45を超えるとボクセル化の都合で「急な斜面と壁を区別できなくなる」アーティファクトが出るとされています) 

    • Step Height:床はフラットなので段差は使いません。そのままでOKです。

    画像

    設定できたら、NavMeshRootのインスペクターに戻り、NavMesh Surfaceの一番下にある「ベイク」ボタン押下。
    水色のメッシュが張り付いたように表示されます。これが、AIにとっての「歩ける地図(NavMesh)」です。


    第3章 番人ゴーレムを組み立てて巡回させる

    マテリアルを4枚作る

    Materialsフォルダに、右クリック ▸「作成」▸「マテリアル」で4枚作ります。

    画像
    • GolemBodyMat:石の灰色(Base Map をグレーに)

    • GolemEye_Patrol:青白。放出(Emission)オン、Emission色をHDRで強度1.5前後

    • GolemEye_Alert:黄色。放出オン

    • GolemEye_Chase:赤。放出オン

    目は実ライト(Point Lightなど)を持たせず、Emissionだけで光らせます。理由はURPの追加ライト事情です。第4弾で触れたとおり、URPのForwardレンダリングパスでは1オブジェクトあたりに影響する追加ライトに上限があります。目にライトを持たせると、暗所の探索で灯りが取り合いになりがちです。Emissionなら「光って見える」だけでライト計算を消費しません。


    見た目を組み立てる(プリミティブのみ)

    数値はすべて目安です。向きは環境により読み替えてください。

    画像
    • ヒエラルキーで空オブジェクトを作り、名前を GolemRoot に。位置は通路C内の床の上、例 (0, 0, 40)。レイヤーは後の章で Ignore Raycast に変えますが、ここではDefaultのままにしておきます。

    • GolemRootを右クリック ▸「3Dオブジェクト」▸「キューブ」。名前を Body に。位置 (0, 1.0, 0)、スケール (1.0, 1.8, 0.8)。マテリアルに GolemBodyMat を割り当て。

    • 同様に子キューブ Head。位置 (0, 2.2, 0)、スケール (0.6, 0.5, 0.6)。マテリアルに GolemBodyMat を割り当て。

    • 同様に子キューブ Eye。Headの前面に、位置 (0, 2.25, -0.31)、スケール (0.34, 0.12, 0.05)。マテリアルに GolemEye_Patrol を割り当て。※-Z側が前面かどうかは向き次第なので、正面に来る位置に読み替えてください。

    📝 Unityの世界では、Z+が常に「顔の向き」です。『GolemController.cs(この章では「器」を作る)』でゴーレムを巡回させますが、顔の向きが逆方向であれば、EyeのZ値の正負を反転すると良いでしょう

    コライダーの整理をします。

    • Body の Box Collider は残します(遮蔽と存在感のため)。

    • Head と Eye の Box Collider は削除します(第4弾のLanternGlowと同じ作法。飾りの部品にコライダーは要りません)。


    NavMeshAgentを付ける

    画像

    GolemRootを選択し、「コンポーネントを追加」▸「Navigation」▸「Nav Mesh Agent」。

    • エージェントタイプ(Agent Type):Humanoid(⚠️ここが第2章で焼いたSurfaceと同じでないと動きません。後述)

    • 速度(Speed):2

    • 角速度(Angular Speed):160

    • 加速度(Acceleration):8

    • 停止距離(Stopping Distance):0.6

    • 自動ブレーキ(Auto Braking):オン

    • 半径(Radius):0.4(見た目に合わせ)

    • 高さ(Height):2.2(見た目に合わせ)


    巡回ウェイポイントを置く

    • 空オブジェクトを4つ作り、GW1〜GW4 と名付けます。

    • 例:GW1 (0, 1.5, 38) → GW2 (0, 1.5, 45) → GW3 (4, 1.5, 50) → GW4 (4, 1.5, 56)。深層エリア(通路C〜直線D〜宝物庫手前)を往復巡回するイメージです。※ YはNavMeshの床高さに読み替えてください。

    💡(コツ)第8弾で精霊用に置いた W1〜W10 とは別物です。番人用は GW(Golem Waypoint)で始めて、混同しないようにしましょう。


    GolemController.cs(この章では「器」を作る)

    この章では状態機械の骨組みだけ作り、巡回(Patrol)だけを動かします。視界やゲージは後の章で「予約席」を開けておきます。

    // ==========================================================
    // 📄 スクリプト:GolemController.cs
    // ==========================================================
    using UnityEngine;
    using UnityEngine.AI; // NavMeshAgent を使うために必要です
    
    // 番人ゴーレムの頭脳です。今回はまず「巡回」だけを動かします。
    public class GolemController : MonoBehaviour
    {
        // --- 番人がとりうる状態を、名前付きの選択肢(enum)で定義します ---
        // Patrol=巡回, Alert=警戒, Chase=追跡, Lost=見失い
        // enum にしておくと、switch で「今どの状態か」を読みやすく分岐できます。
        public enum GuardState { Patrol, Alert, Chase, Lost }
    
        [Header("巡回設定")]
        [SerializeField] private Transform[] waypoints;   // 巡回する地点(GW1〜GW4を割り当て)
        [SerializeField] private float waitTime = 1.5f;   // 各地点で待つ秒数
    
        [Header("目のマテリアル(放出オン済みを割り当て)")]
        [SerializeField] private Material eyePatrol;      // 青白
        [SerializeField] private Material eyeAlert;       // 黄
        [SerializeField] private Material eyeChase;       // 赤
        [SerializeField] private Renderer eyeRenderer;    // Eye の Mesh Renderer を割り当て
    
        // ⏳予約席(a):視界パラメータとランタン参照。第4〜5章で有効化します。
        //             ここで下の行のコメントを外して CanSeePlayer() などを書くと、
        //             LanternController への参照や viewDistance がまだ噛み合わず
        //             error CS0246(型が見つからない)/ CS1061(メンバーが無い)
        //             のもとになります。今はコメントのまま置いておきます。
        // [Header("視界設定(第4章で有効化)")]
        // [SerializeField] private Transform playerTarget;      // プレイヤーの狙点
        // [SerializeField] private LanternController playerLantern; // ランタン(IsOn参照)
        // [SerializeField] private float viewDistance = 6f;
        // [SerializeField] private float viewAngle = 90f;
        // [SerializeField] private float eyeHeight = 2.2f;
    
        // ⏳予約席(b):検知ゲージUI参照。第6章で有効化します。
        //             いま「gauge.SetLevel(...)」と書くと、gauge がまだ
        //             宣言されていないため error CS0103、型を書いても
        //             DetectionGaugeUI クラスが未作成なら error CS0246 になります。
        // [SerializeField] private DetectionGaugeUI gauge;
    
        private NavMeshAgent agent;       // 自分のNavMeshAgent
        private int currentIndex = 0;     // 今どのウェイポイントを目指しているか
        private float waitTimer = 0f;     // 待機の残り計測
        private bool isWaiting = false;   // 待機中フラグ
        private GuardState state = GuardState.Patrol; // 現在の状態
    
        void Awake()
        {
            // 自分に付いているNavMeshAgentを取得します。
            agent = GetComponent<NavMeshAgent>();
        }
    
        void Start()
        {
            // 開始時は巡回状態。目を青白に。最初の地点を目指します。
            ApplyEyeMaterial(GuardState.Patrol);
            if (waypoints != null && waypoints.Length > 0)
            {
                agent.SetDestination(waypoints[currentIndex].position);
            }
        }
    
        void Update()
        {
            // 今回は Patrol の中身だけ実装します。
            switch (state)
            {
                case GuardState.Patrol:
                    PatrolUpdate();
                    break;
                case GuardState.Alert:
                    // 第5章で実装します
                    break;
                case GuardState.Chase:
                    // 第5章で実装します
                    break;
                case GuardState.Lost:
                    // 第5章で実装します
                    break;
            }
        }
    
        // --- 巡回の処理 ---
        private void PatrolUpdate()
        {
            if (waypoints == null || waypoints.Length == 0) return;
    
            if (isWaiting)
            {
                // 待機中:タイマーを減らし、0になったら次の地点へ。
                waitTimer -= Time.deltaTime;
                if (waitTimer <= 0f)
                {
                    isWaiting = false;
                    currentIndex = (currentIndex + 1) % waypoints.Length; // 輪のように巡回
                    agent.SetDestination(waypoints[currentIndex].position);
                }
                return;
            }
    
            // 到達判定の定石イディオムです。
            // ・pathPending 中は remainingDistance が不正確(Infinityになりうる)なので除外
            // ・止まる距離まで来て、経路が無い or 速度がほぼ0 なら「着いた」とみなす
            if (!agent.pathPending &&
                agent.remainingDistance <= agent.stoppingDistance &&
                (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)) 
            {
                // 着いたので、待機に入る
                isWaiting = true;
                waitTimer = waitTime;
            }
        }
    
        // --- 目のマテリアルを状態に応じて差し替えます(sharedMaterial方式) ---
        // 第7弾の教訓どおり、放出オン済みマテリアルを丸ごと差し替えます。
        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;
            }
        }
    
        // --- 動作確認用:エディタで右クリックすると次の地点へ飛べます ---
        [ContextMenu("次の地点へ")]
        private void DebugGoNext()
        {
            if (waypoints == null || waypoints.Length == 0) return;
            currentIndex = (currentIndex + 1) % waypoints.Length;
            agent.SetDestination(waypoints[currentIndex].position);
            isWaiting = false;
        }
    }

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

    アタッチと参照割当(GolemRootを選択して)

    画像
    • GolemRoot に GolemController をアタッチ。

    • Waypoints:サイズを4にし、GW1〜GW4を順に割り当て。

    • Wait Time:1.5

    • Eye Patrol:GolemEye_Patrol

    • Eye Alert:GolemEye_Alert

    • Eye Chase:GolemEye_Chase

    • Eye Renderer:子オブジェクト Eye をドラッグ(EyeのMesh Renderer)

    ⚠️(注意)予約席(a)(b)のコメント部分は、今はコメントのままにしておきます。ここを今うっかり有効化すると、まだ存在しないクラス(LanternControllerの参照追加やDetectionGaugeUI)に触れて error CS0246(型が見つからない)や error CS1061(メンバーが無い)になります。第4章以降で順に開けていきます。連載恒例の⏳予約席です。


    ■ 動作確認
    ゲームを開始(▶)して確認しましょう。

    • ゴーレムが GW1→GW2→GW3→GW4→GW1… と巡回する

    • 各地点で約1.5秒待機してから次へ進む

    • 目が青白く光っている

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


    第4章 視界判定と遮蔽 — 三段のフィルターで「見える」を作る

    見えるとは何か(三段の関門)

    「見える」を一発の判定で作ろうとすると破綻します。本連載では三段のフィルターに分けます。

    画像
    1. 距離:遠すぎたら見えない。

    2. 角度:視野角の外(背後など)は見えない。

    3. 遮蔽:正面で近くても、壁の向こうは見えない。

    近くても後ろなら見えない。正面でも壁越しなら見えない。この三段を全部くぐり抜けたときだけ「見つかった」とします。


    Playerレイヤーを新設する

    遮蔽判定で「壁に当たったか/プレイヤーに当たったか」を切り分けるため、プレイヤー専用のレイヤーを用意します。

    画像
    • メニュー「編集(Edit)」▸「プロジェクト設定(Project Settings)」▸「タグとレイヤー(Tags and Layers)」を開きます。

    • 「User Layer 7」の欄に Player と入力します。

    • ヒエラルキーで model_test(プレイヤーのアバター)を選択します。

    • インスペクター右上の「Layer」ドロップダウンから Player を選びます。

    • 「子も変更しますか?」と聞かれたら「はい、子を変更する(Yes, change children)」を選びます。

    ■ 既存機能が壊れない理由
    「既存機能が壊れないか?」と不安になりますが、大丈夫です。理由を確認しておきましょう。

    画像
    レイヤーを変えても、既存機能が壊れない理由
    1. Interactor のレイマスクは "Interactable" のみを見ているので、Playerレイヤーは無関係。

    2. 各トリガー(Depth/Return/Teleport/Junction)は other.GetComponent<CharacterController>() != null でプレイヤーを判定しているので、レイヤーは問いません。

    3. Cinemachineの追従もレイヤーに依存しません。


    ゴーレム自身を Ignore Raycast にする

    画像
    • GolemRoot を選択します。

    • 「Layer」ドロップダウンから Ignore Raycast(組み込みのBuiltin Layer 2)を選びます。

    • 「子も変更しますか?」で「はい、子を変更する」を選びます。


    ■ Ignore Raycast の正しい使いどころ

    第5弾のレイヤー表で「Ignore Raycast は予約済み」とだけ紹介して素通りしましたね。ここで回収します。

    ゴーレムは自分の目(Eye付近)からプレイヤーへ向けてレイ(見えない光線)を撃ちます。ところが、目のすぐ前には自分のBody(体のコライダー)があります。何も対策しないと、撃ったレイが自分の体に当たって「壁に遮られた!」と誤判定してしまいます。

    画像

    そこでゴーレムを Ignore Raycast レイヤーに入れます。このレイヤーは「Raycast系のクエリを素通しする」特別なレイヤーです。レイヤー1つで、自分の体に当たる事故を根絶できます。

    📝 ただし Ignore Raycast でも、OnTriggerEnter などの物理接触は普通に起きます。素通しになるのはRaycast系のクエリだけです。だから第5章で作る「捕獲トリガー」はちゃんと機能します。安心してください。


    GolemController.cs への差分(予約席(a)を有効化)

    分かりやすさを優先して、改修後の全体像を再掲します。今回は視界判定に必要な変数とメソッドを足します。

    画像
    視界判定を組み込む 4つの変更点
    1. 予約席(a)のコメントを外し、playerTarget / viewDistance / viewAngle / eyeHeight / obstructionMask を有効化。

    2. CanSeePlayer() メソッドを追加(距離→角度→遮蔽の三段)。

    3. 動作確認用に、Patrol中に見えたらログを出す処理を追加。

    4. 視界の扇を描く OnDrawGizmosSelected を追加。

    // ==========================================================
    // 📄 スクリプト:GolemController.cs (第4章版:視界判定を追加)
    // ==========================================================
    using UnityEngine;
    using UnityEngine.AI;
    
    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;
    
        // ⏳→✅ 予約席(a)を有効化:視界設定
        [Header("視界設定")]
        [SerializeField] private Transform playerTarget; // アバターを割当(狙点は胸元)
        [SerializeField] private float viewDistance = 6f; // この章では固定6m
        [SerializeField] private float viewAngle = 90f;   // 視野角(左右合わせて90度)
        [SerializeField] private float eyeHeight = 2.2f;  // 目の高さ
        private LayerMask obstructionMask;                // 遮蔽とみなす壁のマスク
    
        // ⏳予約席(b):検知ゲージUI(第6章で有効化)
        // [SerializeField] private DetectionGaugeUI gauge;
    
        private NavMeshAgent agent;
        private int currentIndex = 0;
        private float waitTimer = 0f;
        private bool isWaiting = false;
        private GuardState state = GuardState.Patrol;
    
        void Awake()
        {
            agent = GetComponent<NavMeshAgent>();
            // 壁マスク=Defaultレイヤー。プレイヤーはPlayerレイヤーに移したので
            // このマスクには含まれません。「当たった=壁」とシンプルに扱えます。
            obstructionMask = LayerMask.GetMask("Default");
        }
    
        void Start()
        {
            ApplyEyeMaterial(GuardState.Patrol);
            if (waypoints != null && waypoints.Length > 0)
                agent.SetDestination(waypoints[currentIndex].position);
        }
    
        void Update()
        {
            switch (state)
            {
                case GuardState.Patrol:
                    PatrolUpdate();
                    // 動作確認:巡回中に見えたらログを出す(第5章で状態遷移に置き換え)
                    if (CanSeePlayer())
                        Debug.Log("見つけた!");
                    break;
                case GuardState.Alert:
                    break; // 第5章
                case GuardState.Chase:
                    break; // 第5章
                case GuardState.Lost:
                    break; // 第5章
            }
        }
    
        private void PatrolUpdate()
        {
            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 (!agent.pathPending &&
                agent.remainingDistance <= agent.stoppingDistance &&
                (!agent.hasPath || agent.velocity.sqrMagnitude == 0f))
            {
                isWaiting = true;
                waitTimer = waitTime;
            }
        }
    
        // --- 三段の視界判定 ---
        public bool CanSeePlayer()
        {
            if (playerTarget == null) return false;
    
            // 目の位置と、プレイヤーの狙点(胸元=足元から1.2m上)
            Vector3 eye = transform.position + Vector3.up * eyeHeight;
            Vector3 targetPoint = playerTarget.position + Vector3.up * 1.2f;
            Vector3 toPlayer = targetPoint - eye;
    
            // ① 距離:二乗どうしで比較(平方根を取らない分、軽い。第8弾の精霊と同じ考え方)
            if (toPlayer.sqrMagnitude > viewDistance * viewDistance) return false;
    
            // ② 角度:前方向とプレイヤー方向の角度が、視野角の半分より大きければ範囲外
            float angle = Vector3.Angle(transform.forward, toPlayer);
            if (angle > viewAngle * 0.5f) return false;
    
            // ③ 遮蔽:目から狙点まで線を引き、壁マスクに当たれば遮られている
            //   QueryTriggerInteraction.Ignore で、迷路内の透明トリガーを無視します。
            if (Physics.Linecast(eye, targetPoint, out RaycastHit hit,
                                  obstructionMask, QueryTriggerInteraction.Ignore))
            {
                // 壁マスク(Default)に当たった=壁が間にある=見えない
                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;
            }
        }
    
        [ContextMenu("次の地点へ")]
        private void DebugGoNext()
        {
            if (waypoints == null || waypoints.Length == 0) return;
            currentIndex = (currentIndex + 1) % waypoints.Length;
            agent.SetDestination(waypoints[currentIndex].position);
            isWaiting = false;
        }
    
        // --- 視界の扇を描く簡易ギズモ(選択中に表示) ---
        private void OnDrawGizmosSelected()
        {
            Vector3 eye = transform.position + Vector3.up * eyeHeight;
            Gizmos.color = Color.yellow;
            // 左右の境界線
            Vector3 left = Quaternion.Euler(0, -viewAngle * 0.5f, 0) * transform.forward;
            Vector3 right = Quaternion.Euler(0, viewAngle * 0.5f, 0) * transform.forward;
            Gizmos.DrawLine(eye, eye + left * viewDistance);
            Gizmos.DrawLine(eye, eye + right * viewDistance);
        }
    }

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

    ■ 3つのポイント
    今回の改修の3つのポイントについてです。

    ① 距離 (Distance) の判定

    画像

    まずは距離。遠すぎる相手は、角度も壁も調べる前に脱落させます(無駄な計算(Raycast等)を早めに切り上げることで、ゲームの処理落ちを防ぐセオリーです)。

    ② 角度 (Angle) の判定

    画像

    比べるのは「視野角の半分」です。90度の視界なら、前方向から左右45度までが範囲内(真横や背後にいるプレイヤーは、ここで見えていないと判定されて処理が終わります)。

    ③ 遮蔽 (Occlusion) の判定

    画像

    目から胸元へ線を1本引くだけ。壁に当たれば見えない(迷路に置いた透明な仕掛けトリガーは Ignore で素通しします)。


    ■ 追加の参照割当(GolemRoot)

    画像
    • Player Target:model_test(主人公アバター) をドラッグ

    • View Distance:6

    • View Angle:90

    • Eye Height:2.2


    【補足】Collide と Ignore:同じ道具の真逆な使い道

    画像

    ここで第5弾のInteractorとの対比を回収します。同じ QueryTriggerInteraction という引数を、真逆に使っています。

    画像

    目には見えない「透明なトリガー(Is Trigger)」に対して、Raycast(光線)をどう振る舞わせるか。目的によって、引数の設定を180度変える必要があります。


    ■ 中空メッシュの壁でもRaycastが効く理由(バックフェース)

    画像

    UnityのRaycastは、面(ポリゴン)の「裏側」はすり抜ける仕様になっています。しかし、第3弾で迷路を作った際、箱を「中空化(法線を反転)」したため、壁の内側(通路側)が「表」を向いています。そのため、通路の内側から撃った光線はしっかりと壁の表面に当たり、特別な設定をしなくても正常に壁を検知できます。


    ■ 動作確認
    ゲームを開始(▶)して確認しましょう。

    【Unity初心者🐣】
    NavMeshで『番人ゴーレム』を製造🗿

    ・番人の正面や範囲内にプレイヤーが入ると「見つけた!」ログが出る
    ・背後に回る/壁越し/距離外では出ない
    ・(柱はまだ無いので)スタブの壁で遮蔽が効くことを確認

    次は、FSM(有限状態機械)を頑張るぞ。#unity #ゲーム制作 #個人開発 pic.twitter.com/ki9WKrIeXf

    — 辛島信芳@Androidアプリ開発🦖 (@quiet_sea) August 29, 2026
    • 番人の正面・範囲内にプレイヤーが入ると「見つけた!」ログが出る

    • 背後に回る/壁越し/距離外では出ない

    • (柱はまだ無いので)スタブの壁で遮蔽が効くことを確認

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


    第5章 状態機械とランタンステルス — 番人に「心」を入れる

    enum FSMの考え方

    番人の振る舞いを「巡回→警戒→追跡→見失い→帰投」という状態の移り変わりで表します。これが状態機械(FSM=Finite State Machine)です。

    画像
    気づいていない/怪しんでいる/確信した。番人の心を、4つの状態とその移り変わりで表します。
    • 巡回(Patrol):ウェイポイントを回る。

    • 警戒(Alert):見えたが確信していない。その場で止まってプレイヤーを見つめ、検知ゲージが溜まる。

    • 追跡(Chase):確信した。走って追う。

    • 見失い(Lost):見えなくなった。最後に見た場所へ行き、少し探してから巡回に戻る。

    市販のステルスゲームも、おおむね「気づいていない/怪しんでいる/完全に発見」の三段階です。今回はそれをenumで素直に表現します。


    先に JudgeFeedbackUI へ ShowMessage を足す

    この章のGolemControllerは、捕まったときに「見つかった……!」と画面に出します。その出し先が、第7弾で作った JudgeFeedbackUI です。ところが今のJudgeFeedbackUIは Show(bool correct, string tellName) しか持っておらず、文言が「せいかい!/はずれ……+(見分けポイント:〜)」に固定されています。

    画像
    呼ばれる側(JudgeFeedbackUI)を先に用意してから、呼ぶ側(GolemController)を書きます。逆にするとコンパイラが CS1061 で止めてくれます。

    ⚠️ 先にこちらを足してください。 順番を逆にしてGolemControllerを保存すると、JudgeFeedbackUI に ShowMessage が無いため、コンパイルエラーで止まります。

    ■ 変更点

    • public void ShowMessage(string text) を追加。ラベルに文字を直接セットし、既存のフェードコルーチンを起動するだけ。

    • 既存の Show(bool, string) は消さずに温存します(後方互換。宝箱の判定演出は今までどおり動きます)。

    • 差し込み位置は、既存の Show(bool correct, string tellName) メソッドの直後。

    // ==========================================================
    // 📄 スクリプト:JudgeFeedbackUI.cs(該当メソッド周辺の差分)
    // ==========================================================
        // 既存:せいかい/はずれ の判定演出(温存します)
        public void Show(bool correct, string tellName)
        {
            // …既存の実装はそのまま…
        }
    
        // 💡追加:任意のメッセージをフェード表示する(後方互換)
        public void ShowMessage(string text)
        {
            label.text = text;                           // ラベルに直接セット
            if (routine != null) StopCoroutine(routine); // 走っていたら止めてから
            routine = StartCoroutine(FadeRoutine());     // 既存のフェードを再利用
        }

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

    label(TextMeshProUGUI)、routine(走行中のコルーチンを覚えている変数)、FadeRoutine() は、いずれも第7弾で作った既存のメンバーです。フィールド名をご自身で変えている方は、そちらに読み替えてください。新しくコルーチンを書く必要はありません。フェードの実務は、既にあるものを呼ぶだけです。


    GolemController.cs 完成形

    本記事で最長のコードです。省略なしで全掲載します。

    // ==========================================================
    // 📄 スクリプト:GolemController.cs (第5章:完成形)
    // ==========================================================
    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):頭上マークとゲージ(第6章で有効化)
        // [SerializeField] private DetectionGaugeUI gauge;
        // [SerializeField] private TextMeshPro alertMark;
    
        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;  // 捕獲後、追い直さないクールダウン
    
        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()
        {
            // テレポート(霧で戻される)中は、番人は一切追いかけません。
            if (MazeDirector.Instance != null && MazeDirector.Instance.IsReturning)
                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;
            }
    
            // ⏳予約席(b):第6章でここに gauge?.SetLevel(detectionLevel) 等を足します。
            // いま書くと gauge が未宣言で error CS0103 になるため、まだ書きません。
        }
    
        // --- 巡回 ---
        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弾でここに警戒音・発見音を差し込みます
        }
    
        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)
        {
            // 戻し処理の最中は無視(テレポート中に二重発火しないため)
            if (MazeDirector.Instance != null && MazeDirector.Instance.IsReturning) return;
            // CharacterControllerを持つ=プレイヤー(連載の作法)
            if (other.GetComponent<CharacterController>() == null) return;
    
            // 「見つかった」演出→霧で戻す
            JudgeFeedbackUI.Instance.ShowMessage("見つかった……! 霧に包まれて戻される");
            MazeDirector.Instance.ReturnPlayer(golemReturnAnchor);
            ResetToPatrol();
        }
    
        // --- 捕獲後:ゲージ0・巡回・最寄りWPへ。しばらく追い直さない ---
        private void ResetToPatrol()
        {
            detectionLevel = 0f;
            cooldownTimer = 2f; // 2秒は再検知しない
            ReturnToPatrol();
        }
    
        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;
        }
    }


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

    画像
    見つかった瞬間に即アウトではありません。ゲージが溜まりきるまでの1.2秒が、隠れ直すための猶予になります。

    追跡中のSetDestinationは毎フレーム呼ばず、setDestInterval(本記事は0.3秒)ごとに呼んでいます。経路計算は少し重いので、追跡のたびに毎フレーム再計算すると無駄が多いのです。0.2〜0.5秒間隔で更新するのが定石です。


    ランタン連動=第5弾IsOnの伏線回収

    viewDistance = playerLantern.IsOn ? lanternOnDistance : lanternOffDistance; の1行が、今回のステルスの心臓です。

    画像
    第5弾で公開しておいた IsOn が、ここで効きます。灯りが、探索の道具であり同時に見つかる原因にもなる。この1行がステルスの心臓です。
    • 点ければ(IsOn=true):viewDistanceが12mに。遠くから見つかります。でも足元は明るくて探索は快適。

    • 消せば(IsOn=false):viewDistanceが5mに。かなり近づけます。でも自分の視界も暗くて、道や仕掛けが見えづらい。

    「見つかりにくさ」と「自分の見やすさ」がトレードオフになる。これが第5弾で public bool IsOn => isOn; をわざわざ公開しておいた狙いでした。灯りが、ただの光源からゲームの駆け引きに昇華した瞬間です。


    物陰(柱)を作る

    隠れる場所が無いとステルスは成立しません。ProBuilderのキューブで柱を置きます(第3弾の作法。中空化は不要、ただの柱です)。

    画像

    ■ Pillar_A(通路C・西の壁際)

    • メニュー「ゲームオブジェクト」▸「ProBuilder」▸「キューブ」で作成。名前を Pillar_A に。

    • Pro Builder Shape のサイズを X:0.8 / Y:3 / Z:0.8 に。

    • Transformの位置を X:-1.6 / Y:1.5 / Z:41、回転は 0 のまま。

    ■ Pillar_B(直線D・東の壁際)

    • 同じくキューブを作成。名前を Pillar_B に。

    • サイズは同じ X:0.8 / Y:3 / Z:0.8。

    • Transformの位置を X:5.6 / Y:1.5 / Z:53、回転は 0 のまま。

    💡(コツ)柱を東西バラバラに置いたのは、隠れる側を毎回変えて単調さを避けるためです。通路CはZ:37の東壁際に石碑があるので、柱は西側にしました。Zは巡回ウェイポイントの中間(GW1↔GW2、GW3↔GW4)を狙っています。番人の動線上に物陰がある配置です。

    画像

    柱を置いたら、NavMeshRootのNavMesh Surfaceで「Bake」をもう一度押してください。柱は障害物なので、その周りにNavMeshの切り欠き(穴)ができます。これで番人は柱を避けて歩き、プレイヤーは柱の陰に隠れられます。再ベイクを忘れると、番人が柱をすり抜けようとして詰まります。


    ゴーレム用ReturnAnchorを新設

    画像
    捕まったときの一連の流れ

    捕まったときに戻される地点を作ります。第8弾のReturnAnchorと同じ考え方です。

    画像

    ヒエラルキーで右クリック ▸「空のオブジェクトを作成」。名前を GolemReturnAnchor に。位置 X:0 / Y:1 / Z:37。回転 X:0 / Y:0 / Z:0。

    📝 Y:1はプレイヤーの立ち位置の高さです(第8弾のReturnAnchor①〜③と同じ値。めり込む・浮く場合は微調整してください)。回転も状況により、適宜調整してください。

    画像

    GolemRootを選択し、GolemControllerの Golem Return Anchor 欄に、いま作った GolemReturnAnchor をドラッグして割り当て。


    ■ 捕獲用のトリガーを足す
    番人に触れたら捕まる、という当たり判定を作ります。

    画像

    ヒエラルキーで GolemRoot を選択。
    「コンポーネントを追加」▸「Physics」▸「Box Collider」をもう1つ追加します。Is Trigger にチェックを入れます。Center を X:0 / Y:1.0 / Z:0。Size を X:1.4 / Y:2.0 / Z:1.4。

    Centerを Y:1.0 にするのは、GolemRootの原点が足元にあるためです。0のままだと判定の箱が床に半分めり込みます。Sizeの1.4は体(幅1.0)より少し広め。狭いとすり抜け、広すぎると理不尽なので、1.2〜1.8で好みに調整してください。

    ⚠️ 今回のGolemRootですが、Box Colliderは2つある状態が正解です。子のBodyに付いているものは遮蔽用なので、そちらのIs Triggerは触らないでください(触ると番人が壁をすり抜けます)。


    シーン割当の一覧表

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

    画像
    model_testはアバターのオブジェクト
    • Player Target:model_test(アバターのルート)

    • Player Lantern:model_test(LanternControllerが付いているオブジェクト)

    • Golem Return Anchor:GolemReturnAnchor

    • Waypoints:サイズ4で GW1〜GW4

    • Eye Renderer / Eye Patrol / Eye Alert / Eye Chase:第3章で割当済み

    数値の欄(View Angle、Lantern On/Off Distance、Fill Time、Chase Speed など)は、すべてコードの初期値のままでOKです。手触りを変えたくなったら、あとからインスペクターで調整できます。

    なお、第4章にあった View Distance の欄は消えています。ランタンの点灯・消灯で毎フレーム決まる値になったため、インスペクターからは外しました。代わりに Lantern On Distance(12)と Lantern Off Distance(5)で調整します。

    ⚠️ 1つでも「なし(None)」が残っていると、その機能だけが無言で動きません。特に Player Lantern が空だと、ランタンを消しても視界が縮まらず「ステルスが効かない」状態になります。


    ゴーレムの目のマテリアル

    ゴーレムは「状態」により、目の色が変わります。念の為、3枚のマテリアル(GolemEye_Patrol、GolemEye_Alert、GolemEye_Chase)で、放出の設定を確認しておきましょう。

    ■ 例:GolemEye_Chase

    画像
    • 放出:チェックがオンになっているか

    • 放出マップ:右の色バー(HDRと書かれた帯)をクリックすると「HDR カラー」のウィンドウが開きます。ベースマップと同じ色(Chaseなら赤)を選び、下部の「強さ」を 3 前後に。

    ⚠️ 放出のチェックを入れるだけでは光りません。放出マップの色が黒(初期値)のままだと、発光量ゼロになります。色を必ず設定してください。

    「GolemEye_Patrol」「GolemEye_Alert」「GolemEye_Chase」の3つとも「強さ」を同じ値に揃えるのがポイントです。バラバラだと、色の違いではなく明るさの違いに見えてしまいます。第4弾で環境光をほぼ黒まで落としているので、1.5では暗所で埋もれます。3前後がおすすめです。


    ■ 動作確認
    ゲームを開始(▶)して確認しましょう。

    【Unity初心者🐣】
    『番人ゴーレム』にFSM「有限状態機械(状態遷移)」を実装🗿
    ゴーレムの視界の範囲内に入ると、スピードアップして巡回範囲を超えて追いかけてくる😱

    捕まると霧ワープ。

    柱や壁でゴーレムの視界を遮れる、さらにランタンを消すと視界範囲が縮まる#unity #ゲーム制作 #個人開発 pic.twitter.com/W4hTiDCqlc

    — 辛島信芳@Androidアプリ開発🦖 (@quiet_sea) August 30, 2026
    • ランタン点灯で遠く(約12m)から発見される→目が黄(警戒)→赤(追跡)

    • 捕まると「見つかった……!」が出て、霧に包まれて手前へ戻される

    • 戻された後、番人は巡回に戻っている

    • ランタン消灯+柱の陰に入ると、しばらくして目が青白(巡回)に戻る=見失われた

    • 戻し処理の最中、番人は追いかけてこない

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

    🏮ランタン
    ・ランタン点灯中:正面12m以内に入ると発見される
    ・ランタン消灯中:正面5m以内に入ると発見される


    第6章 検知ゲージと頭上マーク — 警戒を「見える化」する

    検知ゲージ(スクリーンUI)を作る

    第5章で作った検知ゲージ(detectionLevel)は、数字として溜まっているだけで画面には何も出ていません。これをプレイヤーの目に見える形にします。UI構築は1操作ずつ、端折らずに書きます。

    1. 背景を作る

    画像
    • ヒエラルキーの Canvas を右クリック ▸「UI」▸「画像」。

    • 名前を GaugeBack に変更。

    • インスペクターのRect Transformで、アンカープリセットを開きtop center(上・中央)を選択。

    • 位置 X:0 / Y:-60、幅(Width)260 / 高さ(Height)18。

    • Image(スクリプト)の色(Color)を黒にし、アルファ(A)を150前後の半透明に。

    • レイキャストターゲット(Raycast Target)のチェックを外します(表示専用なので、クリックを遮らないようにします)。

    2. 塗りつぶし部分を作る

    画像
    • GaugeBack を右クリック ▸「UI」▸「画像(Image)」。

    • 名前を GaugeFill に変更(GaugeFillはGaugeBackの子になります)。

    • アンカープリセットで「ストレッチ(stretch・全方向)」を選択(右下のアイコン)。Left / Right / Top / Bottom をすべて 2 に(2pxの縁取り)。

    • こちらも レイキャストターゲットのチェックを外します。

    ⚠️(注意)レイキャストターゲットを外し忘れると、画面上部のゲージが「見えない板」としてクリックを吸ってしまい、第6弾で左上に置いた言語ドロップダウンなどが押せなくなることがあります。表示するだけのUIは、外しておくのが定石です。

    3. GaugeFillを塗りゲージにする

    画像
    画像
    • GaugeFillのImageで、ソース画像(Source Image)の右にある「⊙」ボタンを押します。

    • 出てきたオブジェクトピッカーの検索欄に UISprite と入力し、組み込みスプライトの UISprite を選びます。

    • 画像タイプ(Image Type)を「塗りつぶし(Filled)」に。

    • 塗りつぶし方法(Fill Method)を「水平(Horizontal)」に。

    • Fill Origin を「Left(左)」に。

    • 色(Color)を黄に。

    📝(補足)Unity 6のUI Imageは、ソース画像がNone(未指定)のままだとインスペクターにImage Type(画像タイプ)の項目自体が現れません。Unity Discussionsでも「Source Imageを設定するまでImage Typeのセクション(Use Sprite MeshやPreserve Aspectを含む)が隠れる」と指摘されています。だからここでは確実に組み込みの UISprite を割り当ててから、Filledを選びます。これが「必ず動く」手順です。

    4. ゲージ全体をまとめて消せるように

    画像
    • GaugeBack を選択し、「コンポーネントを追加」▸「Layout」▸「Canvas Group」を選びます(検索欄に Canvas Group と入力しても構いません)。

    • これで検知ゲージが0のとき、丸ごとフェードで消せます(第8弾のJudgePanelと同じ型です)。

    5. DetectionGaugeUI.cs(新規・フル掲載)

    画像
    DetectionGaugeUI.cs の仕組み

    スクリプトはUIの「見た目」を制御しているだけです。番人の状態(level)を受け取って、内側の帯を伸ばして色を変える処理と、全体をフワッと出したり消したりする処理を同時に行っています。

    // ==========================================================
    // 📄 スクリプト:DetectionGaugeUI.cs
    // ==========================================================
    using UnityEngine;
    using UnityEngine.UI; // Image を使うため
    
    // 検知ゲージの見た目だけを担当します。呼び手は番人だけなので、
    // シングルトンにはしません(第7弾の「乱用しない」基準に従います)。
    public class DetectionGaugeUI : MonoBehaviour
    {
        [SerializeField] private Image fillImage;        // GaugeFillを割当
        [SerializeField] private CanvasGroup canvasGroup;// GaugeBackのCanvasGroupを割当
        [SerializeField] private float fadeSpeed = 5f;   // 出現・消滅の速さ
    
        void Awake()
        {
            // 最初は見えない状態から始めます。
            // これが無いと、まだ一度も検知していないのにゲージが
            // 空のまま画面に出っぱなしになります(第8弾のJudgePanelと同じ作法)。
            if (canvasGroup != null) canvasGroup.alpha = 0f;
        }
    
        // 0〜1の値を受け取り、バーの長さ・色・表示を更新します。
        public void SetLevel(float level)
        {
            level = Mathf.Clamp01(level);
    
            // バーの長さ(fillAmount)を更新
            if (fillImage != null)
            {
                fillImage.fillAmount = level;
                // 色を黄→赤に補間(level=0で黄、1で赤)
                fillImage.color = Color.Lerp(Color.yellow, Color.red, level);
            }
    
            // ゲージが0より大きいとき表示(1へ)、0のとき非表示(0へ)
            if (canvasGroup != null)
            {
                float target = (level > 0f) ? 1f : 0f;
                canvasGroup.alpha = Mathf.MoveTowards(
                    canvasGroup.alpha, target, fadeSpeed * Time.deltaTime);
            }
        }
    }

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

    ■アタッチと参照割当

    画像
    • GaugeBack に DetectionGaugeUI をアタッチ。

    • Fill Image:GaugeFill を割当。

    • Canvas Group:GaugeBack 自身(自分のCanvas Group)を割当。

    • Fade Speed:5


    頭上マーク(ワールド空間)を作る

    1. TextMeshProで「!」を出す

    画像
    • ヒエラルキーの GolemRoot を右クリック ▸「3Dオブジェクト」▸「テキスト - TextMeshPro(Text - TextMeshPro)」。

    • 名前を AlertMark に変更。

    • 位置 (0, 2.9, 0)(頭の上)、Rect Transformの幅2 / 高さ1、Scale を (0.5, 0.5, 0.5)。

    • TextMeshProの Font Size を 8、Alignment(配置)を中央(横も縦も中央)。

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

    • テキストは仮で「!」。

    2. 全角記号の注意

    画像
    TextMeshProで文字が豆腐(□)になる原因と解決策

    全角「!」「?」が、NotoSansJP-Medium SDF のStatic焼き文字リスト(約7,000字)に含まれていないと豆腐(□)になります。TextMeshProのStaticフォントは、焼いた時点の文字しか持ちません。Unity公式Issue Tracker(「Characters are missing when generating font atlas」)でも、ソースフォントに無い文字は生成時に欠落するのは「by design(仕様)」だと説明されています。含まれていない場合は、Font Asset Creatorでカスタムリストに「!」「?」を追記して再度焼き直してください。半角の「!」「?」で代用する手もあります。

    3. Billboard.cs(新規・フル掲載)

    // ==========================================================
    // 📄 スクリプト:Billboard.cs
    // ==========================================================
    using UnityEngine;
    
    // 頭上マークが常にカメラの方を向くようにします(看板=ビルボード)。
    // LateUpdateで向きを合わせるのは、カメラ移動の後に確定させるためです。
    public class Billboard : MonoBehaviour
    {
        private Camera cam;
    
        void Start()
        {
            cam = Camera.main; // メインカメラ
        }
    
        void LateUpdate()
        {
            if (cam == null) return;
            // カメラの向きと同じ向きに自分を回す=常に正面を見せる
            transform.forward = cam.transform.forward;
        }
    }

    ※ Assets/Scripts内に Billboard.cs を作成し【AlertMark にアタッチ】します。

    検知ゲージはスクリーン(画面)に貼りましたが、頭上マークは世界(ワールド)に貼りました。理由は視点切替です。この連載はVキーでTPS⇄FPSを切り替えます。マークを世界に置いておけば、どちらの視点でも「ゴーレムの頭の上」という位置関係が破綻しません。スクリーンUIだと視点によってズレやすいのです。

    4. GolemController.cs の予約席(b)を有効化

    以下、変更点。

    • ファイル冒頭に using TMPro; が既にあることを確認(第5章で入れ済み)。

    • 予約席(b)のコメントを外し、gauge と alertMark を有効化。

    • LateUpdateで gauge.SetLevel(detectionLevel) を呼ぶ。

    • 状態に応じて頭上マークのテキストと表示を切り替える。

    ここでやるのは「番人が持っている値を、画面に流す配線」だけです。視界判定や状態遷移のロジックには一切触れません。表示と判断を分けておくと、あとで見た目を変えたくなったときにロジックを壊す心配がありません。

    // ==========================================================
    // 📄 スクリプト:GolemController.cs(第6章の差分・該当部分のみ)
    // ==========================================================
        // ⏳→✅ 予約席(b)を有効化
        [Header("警戒の見える化")]
        [SerializeField] private DetectionGaugeUI gauge;  // GaugeBackを割当
        [SerializeField] private TextMeshPro alertMark;   // AlertMarkを割当
    
        // …(Updateはそのまま。以下のLateUpdateを追加)…
        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;
                }
            }
        }

    ※ Assets/Scripts内の GolemController.cs へ追記します。

    ■ GolemRootに参照を割り当てます。

    • Gauge:GaugeBack(DetectionGaugeUI)を割当

    • Alert Mark:AlertMark を割当

    PhaseDebugHUD.cs を変更

    HUDに「番人: 状態」行を足します。

    • [SerializeField] private GolemController golem; を追加。

    • 表示テキストに「番人: {golem.StateLabel}」行を追加(null安全に)。

    • GolemController に public string StateLabel プロパティ(switch式)を追加。

    • PhaseDebugText の高さを 100→130 に変更。

    まずGolemControllerにStateLabelを足します(第7弾のswitch式の再演です)。

    // ==========================================================
    // 📄 スクリプト:GolemController.cs(StateLabelプロパティ追加)
    // ==========================================================
        // 状態を日本語ラベルで返す(HUD表示用)
        public string StateLabel => state switch
        {
            GuardState.Patrol => "巡回",
            GuardState.Alert  => "警戒",
            GuardState.Chase  => "追跡",
            GuardState.Lost   => "見失い",
            _ => "―"
        };

    次にPhaseDebugHUDの改修です。

    // ==========================================================
    // 📄 スクリプト:PhaseDebugHUD.cs(改修後・全体)
    // ==========================================================
    using UnityEngine;
    using TMPro;
    
    // 現在のフェーズ・失敗数・秘宝の数・番人の状態を画面の隅に出す、開発用のHUD。
    public class PhaseDebugHUD : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI label; // 表示先のTMP
        [SerializeField] private GolemController golem; // 💡追加:GolemRootを割当
    
        void Update()
        {
            // GameManagerをシーンに置き忘れていても、エラーで止まらない保険
            if (GameManager.Instance == null) return;
    
            // $"..." は文字列補間。{ } の中の値が文字列に埋め込まれます
            string text =
                $"フェーズ: {GameManager.Instance.PhaseLabel}\n" +
                $"失敗: {GameManager.Instance.MistakeCount}\n" +
                $"秘宝: {GameManager.Instance.TreasureCount} / 3";
    
            // 💡追加:番人の状態(null安全=golemが未割当でも落ちない)
            if (golem != null)
                text += $"\n番人: {golem.StateLabel}";
    
            // 組み立て終わってから、まとめて1回だけ渡す
            label.text = text;
        }
    }

    最後の行の label は、第7弾でPhaseDebugHUDを作ったときのフィールド名です(表示先のTextMeshProUGUI)。ご自身で名前を変えている方は、そちらに読み替えてください。

    ■ HUDテキストの高さ変更します

    画像
    • ヒエラルキーで PhaseDebugText を選択。

    • Rect Transformの 位置Y を -90 → -120 に変更

    • Rect Transformの 高さ(Height)を 100 → 130 に変更(行が1行増えるため)。

    画像

    PhaseDebugHUD の Golem 欄に GolemRoot を割り当て。

    第5章で ChangeState に入れた Debug.Log($"番人の状態:{next}") は、これで役目を終わりです。コンソールが流れて見づらい場合は、削除するかコメントアウトしても構いません。番人の状態は、これからは画面左上のHUDで確認できます。


    ■ 動作確認

    ゲームを開始(▶)して確認しましょう。

    • 開始直後はゲージが表示されていない

    • 視界に入るとゲージがふわっと現れ、赤に伸びる

    • 頭上に警戒中は「?」、満タン追跡で「!」が出る

    • 離れるとゲージが消える

    • HUDに「番人: 巡回/警戒/追跡/見失い」が出る

    • 左上の言語ドロップダウンが、今までどおりクリックできる

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


    第7章 視線スイッチと、第5弾の宿題回収

    視線スイッチ(内積判定の再利用)

    「見つめると灯る」クリスタルを作ります。宝物庫の入口脇に置きましょう。

    ■ 台座とクリスタルを置く

    画像
    • キューブで台座 SwitchBase を作成。スケール X:0.5 / Y:1 / Z:0.5、位置 X:-1 / Y:0.5 / Z:59

    • スフィアで Crystal を作成。スケール X:0.4 / Y:0.4 / Z:0.4、位置 X:-1 / Y:1.3 / Z:59。Sphere Colliderは削除

    ■ マテリアル2枚を作成
    Materialsフォルダで右クリック ▸「作成」▸「マテリアル」を2回。

    • CrystalOff:暗い青(例:R:40 G:70 B:110)。放出はオフのまま。

    • CrystalOn:明るい青。放出をオン、放出マップの色を明るい青にして「強さ」を3前後に。

    ■ クリスタルに適用する

    画像

    ヒエラルキーで Crystal を選択し、CrystalOff をドラッグ&ドロップで適用します。

    📝 なお、暗い青は第4弾で環境光をほぼ黒まで落としているせいで、想像以上に沈みます。R:40 G:70 B:110 でもかなり暗いので、シーンビューで見えづらければもう少し明るくしてください。「消灯していても、そこに何かあると分かる」程度が目安です。


    GazeSwitch.cs(新規・フル掲載)

    ■ GazeSwitch.cs 視線判定の仕組み
    プレイヤーが「一定時間見つめると点灯する」クリスタルの裏側では、
    毎フレーム3つの関門(距離・角度・遮蔽)をクリアしているかチェックしています。

    ① 距離チェック (Distance)

    画像

    カメラ(アバター)からクリスタルまでの距離が gazeDistance (8m) 以内か調べます。

    ② 角度チェック (Angle)

    画像

    カメラの正面方向(forward)と、クリスタルへの方向(toCrystal)のズレが gazeAngle (30度) 未満か調べます。

    ③ 遮蔽チェック (Linecast)

    画像

    カメラからクリスタルへ見えない線(レイ)を引き、途中に壁(obstructionMask)がないか調べます。

    画像

    すべての条件を満たしたら、1.2秒で点灯。

    // ==========================================================
    // 📄 スクリプト:GazeSwitch.cs
    // ==========================================================
    using UnityEngine;
    
    // プレイヤーが一定時間見つめると点灯するクリスタル。
    // 番人の視界判定と「主語が逆なだけで同じ数学」です。
    public class GazeSwitch : MonoBehaviour
    {
        [SerializeField] private Renderer crystalRenderer; // Crystalを割当
        [SerializeField] private Material crystalOff;      // 放出オフ
        [SerializeField] private Material crystalOn;       // 放出オン
        [SerializeField] private float gazeAngle = 30f;    // 何度以内なら「見ている」
        [SerializeField] private float gazeDistance = 8f;  // 何m以内なら有効
        [SerializeField] private float gazeRequired = 1.2f;// 見つめ続ける必要秒数
    
        private LayerMask obstructionMask;
        private Camera cam;
        private float gazeTime = 0f;
        private bool isOn = false;
    
        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;
            // 遮蔽チェック(壁越しは無効。Ignoreで透明トリガーは無視)
            bool clear = !Physics.Linecast(
                cam.transform.position, transform.position,
                obstructionMask, QueryTriggerInteraction.Ignore);
    
            if (near && facing && clear)
            {
                gazeTime += Time.deltaTime;
                if (gazeTime >= gazeRequired)
                {
                    isOn = true;
                    ApplyMaterial(); // 点灯(sharedMaterial差し替え)
                    // 📢 第11弾でここを仕掛け(扉やギミック)に接続します
                }
            }
            else
            {
                // 見るのをやめたら、未点灯の進捗は少しずつ戻る
                gazeTime = Mathf.MoveTowards(gazeTime, 0f, Time.deltaTime);
            }
        }
    
        private void ApplyMaterial()
        {
            if (crystalRenderer == null) return;
            crystalRenderer.sharedMaterial = isOn ? crystalOn : crystalOff;
        }
    }

    ※ Assets/Scripts内に GazeSwitch.cs を作成し、Crystal(または台座)にアタッチします。

    ■ 参照割当

    画像
    • Crystal Renderer:Crystal を割当

    • Crystal Off:CrystalOff

    • Crystal On:CrystalOn

    番人の視界判定とGazeSwitchは「主語が逆なだけで同じ数学」です。番人は「敵がプレイヤーを見る」、スイッチは「プレイヤーが対象を見る」。どちらも距離→角度→遮蔽の三段。角度は Vector3.Angle、遮蔽は Physics.Linecast と、道具立ても同じです。一度作れば使い回せる、というわけです。点灯は今回は演出だけで、仕掛けへの接続は第11弾で行います。


    Interactor.cs 改修(第5弾の宿題回収)

    第5弾で「壁越しに『調べる』プロンプトが出てしまう」問題を残し、「第9弾で遮蔽判定を扱う」と予告していました。回収します。番人と同じ遮蔽の考え方で解決します。

    ■ 変更点

    • Interactableにヒットした後、カメラ位置→hit.pointの手前まで、壁マスクで二段目のRaycastを撃つ。

    • 遮蔽があれば「見えていない」としてプロンプトを出さない。

    // ==========================================================
    // 📄 スクリプト:Interactor.cs(第9弾・遮蔽判定を追加)
    // ==========================================================
    using UnityEngine;
    using UnityEngine.InputSystem;
    using TMPro;
    
    public class Interactor : MonoBehaviour
    {
        [SerializeField] private Camera mainCamera;
        [SerializeField] private CameraModeSwitcher cameraModeSwitcher; // FPS判定
        [SerializeField] private TextMeshProUGUI promptText;            // プロンプト表示先
        [SerializeField] private float maxDistance = 3f;                // 手が届く距離
    
        [Header("狙い方の設定")]
        // オンにすると、三人称のときも調べられるようになります。
        // レイの起点がカメラではなく「キャラの目の高さ」に変わるため、
        // カメラとキャラの間に壁があっても影響を受けません。
        [SerializeField] private bool allowThirdPerson = false;
        // 上をオンにしたときだけ使う、レイを撃ち出す高さ(メートル)。
        [SerializeField] private float eyeHeight = 1.4f;
    
        private GameControls _controls;
        private InputAction _interactAction;
        private int _interactableMask;
        private int _obstructionMask;   // 💡追加:壁マスク(遮蔽チェック用)
        private IInteractable _current; // 今狙っている対象
    
        private void Awake()
        {
            _controls = new GameControls();
            _interactAction = _controls.Player.Interact;
            _interactableMask = LayerMask.GetMask("Interactable");
            // 💡追加:壁として扱うレイヤー。番人の視界判定とまったく同じ考え方です。
            _obstructionMask = LayerMask.GetMask("Default");
        }
    
        private void OnEnable()  { _controls.Player.Enable(); }
        private void OnDisable() { _controls.Player.Disable(); }
    
        private void Update()
        {
            // UI表示中はプロンプトを消して早期return("[E] 調べる"を出しっぱなしにしない)
            if (UiGate.IsOpen)
            {
                _current = null;
                SetPrompt(null);
                return;
            }
    
            // allowThirdPerson がオフのときだけ、「一人称でなければ何もしない」を適用する。
            if (!allowThirdPerson &&
                (cameraModeSwitcher == null || !cameraModeSwitcher.IsFirstPerson))
            {
                _current = null;
                SetPrompt(null);
                return;
            }
    
            // レイの起点と向きを、モードによって切り替える
            Ray ray;
            if (allowThirdPerson)
            {
                // キャラの目の高さから、キャラの正面へ(三人称でも破綻しない)
                ray = new Ray(transform.position + Vector3.up * eyeHeight, transform.forward);
            }
            else
            {
                // 画面(カメラ)の中央から前方へ(一人称の王道)
                ray = mainCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
            }
    
            if (Physics.Raycast(ray, out RaycastHit hit, maxDistance,
                                _interactableMask, QueryTriggerInteraction.Collide))
            {
                // 💡追加:二段目の遮蔽チェック
                // 同じレイを、今度は壁マスクで「ヒット点の少し手前まで」撃ちます。
                // 何かに当たれば、間に壁があるということ=見えていないので調べさせません。
                // 透明なトリガーに遮られないよう Ignore を指定します(番人の視線と同じ)。
                if (Physics.Raycast(ray, out RaycastHit block, hit.distance - 0.05f,
                                    _obstructionMask, QueryTriggerInteraction.Ignore))
                {
                    _current = null;
                    SetPrompt(null);
                    return;
                }
    
                IInteractable it = hit.collider.GetComponentInParent<IInteractable>();
                if (it != null)
                {
                    _current = it;
                    SetPrompt($"[E] {it.GetPrompt()}");
                    if (_interactAction.WasPressedThisFrame())
                        _current.Interact();
                    return;
                }
            }
    
            _current = null;
            SetPrompt(null);
        }
    
        private void SetPrompt(string text)
        {
            if (promptText == null) return;
            promptText.text = text ?? string.Empty;
            promptText.enabled = !string.IsNullOrEmpty(text);
        }
    }

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

    ■ Interactor.cs 遮蔽判定の仕組み
    「壁の向こうにある宝箱を透視して開けてしまう」バグを防ぐため、
    レイ(光線)を二段構え(ターゲット用と壁チェック用)で撃ち出す仕組みを解説します。

    画像

    最初に撃つレイは、レイヤーマスクに _interactableMask を指定しています。これは「Interactable(調べられるもの)以外はすべてすり抜ける魔法のレイ」です。

    画像

    もし宝箱が見つかったら、全く同じ方向へ、今度は壁(_obstructionMask)を探すレイを撃ちます。距離を hit.distance - 0.05f にしているのが職人技です。少しだけ短くすることで、「宝箱自身の表面」に当たって『壁がある!』と誤認するのを防いでいます。

    画像

    壁の有無でのプロンプト(第5弾で作った PromptText)確認。


    ■ コラム:番人と同じ道具で宿題を解く
    番人の視界も、この「調べる」も、根っこは同じです。「AからBの間に壁があるか?」を Physics.Raycast / Linecast で確かめ、QueryTriggerInteraction.Ignore で透明トリガーを無視する。番人を作る過程で身につけた遮蔽の考え方が、そのまま第5弾の宿題の答えになりました。教材が一周してつながった感じがして、気持ちいいですね。

    📝 第5弾の末尾で配った差し替え版(allowThirdPerson 付きでTPSでも調べられる版)を使っている方も、対処は同じです。Interactableにヒットした直後の同じ位置に、同じ二段目のRaycast(壁マスク+Ignore)を足せばOKです。


    ■ 動作確認
    ゲームを開始(▶)して確認しましょう。

    【Unity初心者🐣】

    ・クリスタルの近くで1.2秒見つめると灯る(視線角度の判定付き)🔮

    ・『番人ゴーレム』検知ゲージや頭上マークを実装(巡回中、警戒中、追跡中)🗿

    ・ゲージUIは1操作ずつ解説(Image塗りつぶし・fillAmount)📊#unity #ゲーム制作 #個人開発 pic.twitter.com/RmSV5yiKdH

    — 辛島信芳@Androidアプリ開発🦖 (@quiet_sea) August 31, 2026

    近くまでいって、クリスタルを1.2秒見つめると灯る

    判定の調整
    デフォルトのGaze Angle(視線角度)30 や Gaze Distance(距離)8 はかなり緩い判定です。インスペクターで一時的に調整しながらテストすると良いでしょう。


    おわりに

    今回、実装したもの

    画像
    今回、実装した9つのコア機能
    • NavMeshベイク(現行のNavMesh Surface方式)

    • ウェイポイントを巡回するNavMeshAgentのゴーレム

    • 距離→角度→遮蔽の三段視界判定

    • enum FSM(巡回/警戒/追跡/見失い)

    • ランタン連動ステルス(第5弾IsOnの回収)

    • 捕獲→ReturnPlayerで霧に包んで戻す(第8弾の仕組みの流用)

    • 検知ゲージ+頭上マークで警戒の見える化

    • 見つめると灯る視線スイッチ

    • Interactorの遮蔽判定=第5弾の宿題回収

    コラム

    画像
    • あえて「Unity Behavior」を使わなかった理由
      UnityにはノードでAIを組める強力な公式ツール「Unity Behavior」があります。 しかし今回は、中身がブラックボックスになるのを避け「なぜ動くか」をコードで完全に把握できるよう、あえてシンプルな enum FSM を自作しました。 専用の強力なツールは、ゲームのAIがより複雑になった段階で導入を検討してみましょう。

    • 本格的な人型敵を動かすなら
      歩行アニメーションを持つ本格的な敵を作る場合は『NavMeshAgentの移動速度(agent.velocity.magnitude)をAnimatorに渡して同期させる』のが定石です。今回はアニメ無しのゴーレムでしたが、今後の応用テクニックとしてぜひ頭の片隅に置いておいてください。


    第10弾予告

    画像

    次回は『ゲームの骨格 — シーン・セーブ・音』です。
    タイトル画面とシーン遷移、PlayerPrefsで秘宝のビットフラグ保存、AudioMixerと3Dの音の定位を扱います。第1弾で空実装のままだった OnFootstep(足音)を、ついに回収します。
    そして今回コード内に置いた「📢 効果音差し込み」コメント(番人の状態遷移/視線スイッチの点灯)が、その接続点になります。

     
     
     
    バックエンドエンジニア歴10年以上。設計から実装まで一貫して手がけ、決済APIやKPI集計システムなど、堅牢性が求められる開発現場に長年従事。近年では起業志向をもとに、個人開発やAI駆動開発のリサーチを推進している。

    あなたへのおすすめ