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

【Unity】ScriptableObjectで仕掛けを量産する(データカタログ・重み付き抽選・宝箱の真贋ギミック)

    辛島信芳

    第1章 はじめに

    主旨

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

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

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

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

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

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

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

    7. 🆕 【今回の記事】 第7弾:『データで仕掛けを量産する — ScriptableObjectカタログと抽選機(心臓部) 』

    8. 📅 [執筆予定] 第8弾:『迷いの回廊 — ループ・テレポート・導き』

    9. 📅 [執筆予定] 第9弾:『番人ゴーレム — 巡回AIと、隠れて逃げるアクション』

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

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

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

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

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

    第5弾で「調べる」ができるようになり、第6弾で日本語の文字が世界に置けるようになりました。石碑の刻文を読み、手記のページをめくり、名簿に名を刻む……遺跡はずいぶん「読める場所」になりましたが、まだ決定的に足りないものがあります。遊びの仕掛けです。

    そこで今回は、探索ゲームの定番ギミック「本物と偽物(ミミック)の宝箱」を作ります。広間に一対の宝箱を置き、片方は本物、片方はよくできた偽物。プレイヤーは一人称視点でじっくり観察し、向きのズレ・目の光・余計な飾り・銘板の誤字といった「見分けポイント」からどちらが本物かを見抜いて開ける。という間違い探しゲームです。

    🪞 深層フェーズでは判定がXOR(排他的論理和)で反転する(「違和感のある方こそ本物」)という点がポイントです。

    【Unity初心者🐣】

    深層(フェーズ)が「浅い / 中間」では『違和感のない』宝箱が正解。逆に「深い」だと『違和感のある』宝箱が正解。

    〜 深淵に進むと、世界線が反転する🪞〜ギミックの実装。

    開発デバッグ用のゲームマネージャでの切り替えも便利。#unity #ゲーム制作 #個人開発 pic.twitter.com/t0FTHHZwPZ

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

    ただし、本記事の主役はこの宝箱そのものではありません。主役は、その裏側にある「データで仕掛けを量産する」という考え方=ScriptableObject(スクリプタブルオブジェクト)です。

    見分けポイントを1件ずつ「データのファイル(アセット)」として作り、カタログのように束ね、抽選機がそこから毎回1件を引いて宝箱に適用する。この形にしておくと、見分けポイントを増やしたくなったらアセットを1個追加するだけ。スクリプトは1文字も書き換えずに、仕掛けのバリエーションが増えていきます。第6弾のおわりに「今回作った刻文や手記も、いずれデータ化して管理できるようになります」と予告しましたが、その「データ化」の第一歩が今回です。

    👩‍💻 こんな方に向けた内容です
    ・第1〜6弾から続けて作っている方(既存スクリプトは壊さず、第5弾のInteractorはそのまま使い回します)
    ・仕掛けを増やすたびにスクリプトやプレハブをコピペしていて、限界を感じ始めた方
    ・「ScriptableObject」という単語はよく見るけど、何が嬉しいのか腹落ちしていない方
    ・ガチャや宝箱のような「重み付き抽選」を一度ちゃんと作ってみたい方
    ・「エディタでは動くのにビルドで動かない」系の罠を先回りで潰したい方

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

    📝執筆時点で6.5系の最新パッチは6000.5.8f1まで進んでいます。また第2弾で触れたとおり、6.5は「Supported(サポート版)」なので、次のバージョンが正式リリースされるとサポートが終わります。長期間バージョンを固定したい方は、LTS(6.3、または今後出る6.7)も検討してください。バージョンを上げる際の手順は第2弾がそのまま使えます。


    今回やること・やらないこと(先にお伝えしておきます)

    この仕掛けにはもうひとつ仕込みがあります。遺跡の深さ(フェーズ)によって、出てくる見分けポイントの傾向が変わる——浅い層では分かりやすい「向きのズレ」が主力、深い層では見抜きにくい「目の光」や「銘板の誤字」が主力になる、という難易度カーブです。

    ただし今回作るのは、「フェーズを受け取ると抽選の傾向が変わる仕組み」までです。「ゲームを進めるとフェーズが自動で上がる仕組み」は作りません。本記事の段階では、フェーズはインスペクターで手動で切り替えます。

    画像
    今回はエンジンを載せる回。走り出すのは第8弾

    例えるなら、今回はエンジンを載せる回です。アクセルを繋いで実際に走り出すのは、迷路が完成する第8弾。「遺跡を奥へ進む=フェーズが上がる」と結線した瞬間に、今回インスペクターで設計した難易度カーブが、そのままプレイヤーの体験に変わります。

    なので本記事の本題は、フェーズそのものではなく、その1つ下の階層にあります。仕掛けの中身をスクリプトからデータへ追い出し、「アセットを増やすだけでバリエーションが増える」体制を作ること。 フェーズ別の難易度カーブは、その体制があってこそ実現できる応用例のひとつ、という位置づけです。


    ■ 今回の到達点

    • 「見分けポイント」がScriptableObjectのカタログになり、値の調整がインスペクターだけで完結する

    • 一対の宝箱の片方に、抽選された差分(向き・目の光・飾り・銘板の誤字)が毎回ランダムに付く

    • 直近に出た見分けポイントは連続で出ない(履歴Queue付き抽選機)

    • 「[E] 開ける」で真贋を判定。正解なら宝箱が開き、不正解なら失敗としてカウントされる

    • インスペクターでフェーズ(浅層/中層/深層)を切り替えると、抽選の傾向がその場で変わる

    • 深層フェーズでは判定がXOR(排他的論理和)で反転する(「違和感のある方こそ本物」)

    • 結果がふわっとフェードするUIで表示され、数秒後に自動で次の抽選が回り続ける

    ScriptableObjectは、UnityがCreateメニューを再編して専用テンプレートを追加するなど、ここ数年で「作り方の定番」自体が変わった領域です。加えて今回は、マテリアルの発光(Emission)まわりで「エディタでは光るのにビルドでは光らない」という、第4弾のフォグと同型の罠も踏み抜きました。本記事では実機で確認した現行仕様(Unity 6.5)の情報を提供します。古い記事の内容とズレていたら、こちらを優先してください。


    本記事の構成

    本記事は「知る → 器を作る → 舞台を作る → 管理役を置く → 抽選する → 回す」という流れの、7章構成です。章ごとに動く状態で区切ってあるので、1章ずつ動作を確かめながら進めてください。

    画像
    ※「第1章」は『はじめに』なので略してます
    • 第2章「『コピペ量産』の限界とScriptableObject」
      手を動かす前の準備運動です。もしScriptableObjectを使わなかったら何が起きるのかを思考実験し、「データとロジックの分離」という今回の設計思想を押さえます。

    • 第3章「はじめてのScriptableObject — 見分けポイントのカタログを作る」
      今回の心臓部その1。Unity 6の新しい専用テンプレートからSOクラスを作り、[CreateAssetMenu]でアセットを量産できるようにします。「Play中に書き換えると保存されてしまう」というSO最大の罠もここで体験します。

    • 第4章「一対の宝箱を組み立てる」
      キューブとスフィアだけで、目と銘板と飾りソケットを持つ宝箱ペアを作ります。3D TMP(第6弾)やGetComponentInParent(第5弾)など、これまでの積み上げが一気に効いてくる章です。

    • 第5章「GameManager(簡易版)— 深度フェーズと失敗数」
      第4弾のFogControllerと同じ「シングルトン」の型で、ゲーム全体の状態を預かる管理役を作ります。あわせて開発用HUD(フェーズと失敗数の表示)も組みます。

    • 第6章「抽選機 — カタログから『今回の見分けポイント』を決める」
      今回の心臓部その2。フェーズ別の重み付き抽選(累積和方式)と、直近の重複を防ぐ履歴Queueを実装します。Random.Rangeの意外な仕様も確認します。

    • 第7章「適用と判定 — 調べて、見抜いて、開ける」
      すべてを繋ぎます。差分の適用(switch文)、XORによる判定反転、CanvasGroupでフェードする結果UI。そして本記事いちばんの罠「Emissionがビルドで光らない」問題をここで解説します。

    それでは始めましょう!


    第2章 「コピペ量産」の限界とScriptableObject

    もしScriptableObjectを使わなかったら?

    いきなりコードを書く前に、少しだけ思考実験にお付き合いください。

    「見分けポイント」を4種類(向き・目の光・飾り・誤字)作りたいとします。ScriptableObjectを知らない状態で素直に書くと、たぶんこうなります。

    // ❌ 思考実験:直書き方式(真似しないでください)
    public class ChestGimmick : MonoBehaviour
    {
        void Setup(int pattern)
        {
            if (pattern == 0)      { /* 向きを20度ズラす */ }
            else if (pattern == 1) { /* 目を光らせる */ }
            else if (pattern == 2) { /* 飾りを付ける */ }
            else if (pattern == 3) { /* 銘板を「めさめのたから」にする */ }
        }
    }

    最初は動きます。でも、続けていくとこうなります。

    画像
    データ(値)とロジック(処理)が混在していきます
    • 調整のたびにコードを触る
      「回転20度は分かりやすすぎたから12度に」——この1文字のためにスクリプトを開き、コンパイルを待つことになります

    • 増やすたびにコードが伸びる
      5種類目、6種類目……と足すたびif文が育ち、いつか壊します

    • 全体が見渡せない
      「浅層ではどの見分けポイントが出やすいんだっけ?」に答えるには、コードを全部読むしかありません

    🧩 問題の正体は「データ」と「ロジック」が混ざっていること
    ・「回転は20度」「銘板の誤字は『めさめのたから』」 → これらはデータ(調整したい値)です。
    ・「抽選する」「宝箱に適用する」→ これらはロジック(一度書いたら滅多に変えない処理)です。
    この2つが1つのスクリプトに同居しているせいで、データを1つ直すだけでロジックごとリスクに晒される。これがコピペ量産の限界の正体です。


    ScriptableObjectとは何者か

    そこで登場するのが ScriptableObject(スクリプタブルオブジェクト) です。ひとことで言えば【シーンに置かなくていい、データ専用の入れ物】を、自分で設計してアセット(ファイル)として量産できる仕組みです。

    画像
    2つのスクリプトの役割の違い

    これまで書いてきたスクリプト(PlayerLocomotionやLoreUIなど)はすべて MonoBehaviour でした。MonoBehaviourは「シーンのGameObjectにアタッチして動く」のが前提で、UpdateやStartで毎フレーム働く労働者です。

    対してScriptableObjectは、シーンには置けません。Projectウィンドウに .asset というファイルとして存在し、インスペクターで値を編集できる台帳です。働きはしませんが、「向きのズレは20度」「浅層での出現重みは3」といった値を覚えておくのが仕事です。

    💡 実は、みなさんはScriptableObjectをもう使っています。第6弾で作ったLocalizationのString Table(GameText)や、TMPのフォントアセットは、内部的にScriptableObjectで出来ています。「インスペクターで編集できる、シーンに依存しないデータファイル」——あの触り心地を、今回は自分で設計するわけです。

    ScriptableObjectの代表的な使い道は3つあると言われます。

    1. データの入れ物(データコンテナ)

    2. スクリプト同士の連絡網(イベントチャネル)

    3. 処理の部品化。Unity公式も『Create modular game architecture in Unity with ScriptableObjects』という無料のe-bookでこの3つを詳しく解説しています。

    ただし「2. 」「3. 」は中級のさらに先の話。本記事は「1. 」のデータコンテナだけに絞ります。まずはここを完璧にするのが、いちばんの近道です。

    💡 ちなみに、PHPでMySQLを叩いていた人は、ScriptableObjectを「軽量なRDBのテーブルのようなもの」と捉えると飲み込みが早いらしいです。

    Gemini先生曰く

    今回の設計図

    今回作るものの全体像です。登場人物は4組に整理できます。

    画像
    • 台帳(第3章)
      MimicTellData(ScriptableObject)。見分けポイント1件=アセット1個

    • 舞台(第4章)
      一対の宝箱(TreasureChest×2)と、その進行役(ChestPairController)

    • 審判(第5章)
      GameManager。深度フェーズと失敗数を預かるシングルトン

    • 抽選機(第6章)
      TellLottery。台帳から今回の1件を重み付きで引く

    「台帳(データ)」と「それ以外(ロジック)」が完全に分かれているのがポイントです。ロジックは今回書いたら基本的に触りません。以後、遊びを増やす作業は「台帳のアセットを増やす」だけになります。

    それでは、台帳から作っていきましょう。


    第3章 はじめてのScriptableObject — 見分けポイントのカタログを作る

    【Unity 6の新常識】専用テンプレートからクラスを作る

    さっそくScriptableObjectのクラスを作ります。ここでいきなり、古い記事との差分があります。

    ネットの解説記事のほぼすべては「C#スクリプト(MonoBehaviour)を新規作成して『MonoBehaviour』を手で 『ScriptableObject』 に書き換える」という手順で書かれています。この方法は今でも動きますが、Unity 6ではScriptableObject専用のスクリプトテンプレートが公式に追加されました。せっかくなので新しい正道で作りましょう。

    画像
    1. Projectウィンドウで Assets/Scripts フォルダを開く

    2. 右クリック ▸ 作成(Create) ▸ Scripting ▸ ScriptableObject スクリプト(環境によっては ScriptableObject Script 表記)

    3. ファイル名を MimicTellData にする。

    ⚠️ テンプレートが作ってくれるのは「空の器」だけです
     
    生成された雛形は ScriptableObject を継承したクラスの骨組みで、後述する [CreateAssetMenu] 属性は付いていません。属性は自分で書き足す必要があります(公式マニュアルもその手順で説明しています)。「テンプレを使ったのにメニューに出ない!」と焦ったら、まずこの属性の書き忘れを疑ってください。


    MimicTellData.cs(見分けポイントの台帳)

    生成されたファイルを開き、以下の内容に書き換えます。

    // ==========================================================
    // 📄 スクリプト:MimicTellData.cs(ScriptableObject)
    // ==========================================================
    using UnityEngine;
    
    // 見分けポイントの「種類」。どの手段で差分を付けるかを表します。
    // enum(列挙型)は「決まった選択肢の中から1つ選ぶ」ための型です。
    public enum TellType
    {
        Rotation,   // 向きのズレ(本体をY軸に少し回す)
        EyeGlow,    // 目の光(発光マテリアルに差し替える)
        Prop,       // 余計な飾り(Prefabをくっつける)
        PlateText   // 銘板の誤字(1文字だけ違う文言にする)
    }
    
    // 「見分けポイント1件ぶんのデータ」を入れる台帳。
    // MonoBehaviourではなく ScriptableObject を継承している点に注目してください。
    //
    // [CreateAssetMenu] を付けると、Projectウィンドウの右クリックメニューから
    // このクラスのアセット(.asset)を量産できるようになります。
    [CreateAssetMenu(
        fileName = "Tell_New",               // 生成されるアセットの初期ファイル名
        menuName = "ミミック/見分けポイント",  // 作成メニューでの表示("/"で階層化できる)
        order = 0)]                          // メニュー内での並び順
    public class MimicTellData : ScriptableObject
    {
        [Header("識別")]
        public string tellName = "(名前未設定)"; // 結果UIやログに出す名前
        public TellType tellType = TellType.Rotation; // この見分けポイントの種類
    
        [Header("フェーズ別の出現重み(0=そのフェーズでは出ない)")]
        [Range(0, 10)] public int weightShallow = 1; // 浅層での出やすさ
        [Range(0, 10)] public int weightMiddle  = 1; // 中層での出やすさ
        [Range(0, 10)] public int weightDeep    = 1; // 深層での出やすさ
    
        [Header("Rotation用:Y軸の回転ズレ(度)")]
        public float rotationOffsetY = 20f;
    
        [Header("Prop用:くっつける飾りのPrefab")]
        public GameObject propPrefab;
    
        [Header("PlateText用:差し替え後の銘板の文言")]
        [TextArea(1, 2)] public string fakePlateText = "めさめのたから";
    
        // EyeGlow用のパラメータはここには持ちません。
        // 「どのマテリアルに差し替えるか」は宝箱側(第4章)に持たせます。
        //  使わない欄(例:Rotation型なのにpropPrefab)は空のままで構いません。
    }

    ■ コードの読みどころ

    画像
    3つのポイント
    • [CreateAssetMenu]の3つのパラメータ
      fileName は生成されるアセットの初期名、menuName はメニューでの表示(/ で階層化)、order は並び順です。この属性1行が「クラス」を「量産できる台帳」に変えます

    • [Header]と[Range]
      どちらも第1弾から使ってきたインスペクター整形用の属性です。SOでもそのまま使えます。特に [Range(0, 10)] はスライダーになるので、重みの調整が気持ちよくなります

    • フェーズ別の重みが3つ並んでい
      今回の設計の急所です。「浅層では出ない(0)」も「深層では出やすい(3)」も、この3つの数字だけで表現できます。絞り込みと出現率を1つの仕組みで兼ねる、というわけです

    💡 なぜ public フィールドなの?
    これまでのMonoBehaviourでは [SerializeField] private を使ってきました。SOでも同じ書き方はできますが、SOは「他のスクリプトから読まれるための台帳」なので、読み取りが主目的の今回は素直にpublicにしています(書き込みは第7章まで一切登場しません。むしろ書き込んではいけない理由を、この章の最後でお見せします)。

    (※ SO = ScriptableObject )

    アセットを4つ量産する(データコンテナ)

    コンパイルが通ったら、いよいよ台帳のアセットを作ります。

    画像
    1. Projectウィンドウの Assets 直下に右クリック ▸ 作成 ▸ フォルダ で Data フォルダを作成

    2. Dataフォルダ内で右クリック ▸ 作成 ▸ ミミック ▸ 見分けポイント を選択

    3. 「Tell_New」というアセットが生まれるので、名前を Tell_Rotation に変更

    4. 同じ操作を繰り返し、Tell_EyeGlow / Tell_Prop / Tell_PlateText の計4つを作る


    一覧の中から「ミミック」カテゴリが見つからない場合、以下の2点を確認しましょう。

    1. MimicTellData.csにコンパイルエラーが残っていないか

    2. [CreateAssetMenu]属性を書き忘れていないか

    // [CreateAssetMenu]属性
    [CreateAssetMenu(
        fileName = "Tell_New",             
        menuName = "ミミック/見分けポイント",
        order = 0)] 

    各アセットを選択し、インスペクターで以下のように値を入れます。

    ■ Tell_Rotation(向きのズレ)

    画像
    • Tell Name:向きのズレ / Tell Type:Rotation

    • 重み:浅層 3 / 中層 2 / 深層 1(浅いほど分かりやすい差分が出る設計)

    • Rotation Offset Y:20

    ■ Tell_EyeGlow(目の光)

    画像
    • Tell Name:目の光 / Tell Type:EyeGlow

    • 重み:浅層 0 / 中層 2 / 深層 3(浅層では出ない。あとで絞り込みの動作確認に使います)

    ■ Tell_Prop(余計な飾り)

    画像
    • Tell Name:余計な飾り / Tell Type:Prop

    • 重み:浅層 2 / 中層 2 / 深層 2

    • Prop Prefab:(第4章でPrefabを作ってから割り当てます。今は「なし」のままでOK)

    ■ Tell_PlateText(銘板の誤字)

    画像
    • Tell Name:銘板の誤字 / Tell Type:PlateText

    • 重み:浅層 1 / 中層 2 / 深層 3

    • Fake Plate Text:めさめのたから(正しくは「めざめのたから」。濁点が1つ抜けています)

    📝 銘板の文言をひらがなだけにしているのには理由があります。第6弾で焼いた日本語フォントアセットは Static(静的)、つまり「アトラスに焼き込んだ文字しか表示できない」方式でした。凝った漢字を使うと、文字リストに含まれていない場合に豆腐(□)へ逆戻りします。スクリプトから文字を差し替えるときも、この制約は生きています。 ひらがな・カタカナ・基本漢字なら第6弾の文字リストに確実に含まれているので安全です。


    データコンテナの「2つの目的」

    先ほどの4つのアセットは「データコンテナ」と呼ばれるものです。

    ただのデータファイルを作っているように見えて、実は「ゲームの面白さ」をコントロールする基盤を作っています。

    【目的1】「どう化けるか」のバリエーション(引き出し)を作る

    画像

    プログラム(スクリプト)の中に直接書くのではなく、独立した「ファイル(アセット)」として部品化しておくことで、後からいくらでも種類を増やせるようになります。

    【目的2】重み(Weight)で「難易度バランス」をコントロールする

    画像

    アセットごとに設定した「浅層・中層・深層」の数字。これがそのまま「出やすさ」になります。これにより、プログラムを一切書き換えずに、ゲームの進行に合わせた難易度カーブを作れます。

    ゲームの調整において『スクリプトを開く必要がなくなり、Unityエディタ(インスペクター)でスライダーをいじるだけ』で完結出来るようになります。


    【注意点】Play中にSOを書き換えると「保存」されてしまう

    画像
    エディタ上での「Playモード終了時」の挙動の違い

    MonoBehaviourのインスペクター値は「Play中の変更はPlay終了で元に戻る」のがお約束でした(第4弾でフォグの濃さを試し変えしたときもそうでしたね)。ところがScriptableObjectアセットは、Play中の変更がそのまま保存されます。

    Unity公式マニュアルには「エディタでは、EditモードでもPlayモードでもScriptableObjectへデータを保存できる。一方、ビルドされたゲーム(スタンドアロンプレイヤー)では、ScriptableObjectアセットは読み取り専用」と明記されています。つまりこれはバグではなく仕様です。

    画像
    「セーブデータ錯覚」問題

    恐ろしいのはその非対称性です。エディタでは変更が残るので「セーブデータ代わりに使えるのでは?」と錯覚しますが、ビルドでは再起動のたびにリセットされます。「エディタでは進行状況が残るのに、配布したゲームでは消える」…… 原因に気づきにくい事故の典型です。

    📝 ScriptableObject はあくまで マスターデータ(初期設定のカタログ) 用です。プレイヤーのセーブデータには PlayerPrefs や JSON保存 を使いましょう。


    ■ 本連載での約束事
    この仕様から、SOとの付き合い方のルールが決まります。

    画像
    • SOは読み取り専用の台帳として扱う。 ゲーム実行中にスクリプトからSOのフィールドへ書き込まない

    • 実行中に変わる値(失敗数など)はMonoBehaviour側に持つ。 今回はGameManager(第5章)が担当します

    • セーブしたい値はPlayerPrefs(第6弾で学習、第10弾で本格活用)などのセーブ専用の仕組みへ


    ■ SOの「裏技」と「地味な注意点」

    画像
    • どうしても値を書き換えたい時は?
      Instantiate(元のSO) でランタイム用のコピーを作り、コピー側を書き換えるという定石があります。アセット本体は無傷のまま守れます。今回は使いませんが、頭の片隅に置いておいてください。

    • Git(UVCS)のコミット事故に注意
      SOアセットの値変更は .asset ファイルの変更なので、UVCSやGitでは差分としてコミット対象になります。Play中にうっかり変えた値が、気づかぬままチェックインに紛れ込むことがあります。コミット前の差分確認を習慣にしましょう(変えてしまった Rotation Offset Y は 20 に戻しておいてください)。


    ■ 台帳を多言語化したくなったら?

    画像
    実装時に立ちはだかる「2つの壁」

    「銘板の文言も日本語⇄英語で切り替えたい」と思った方、鋭いです。実はSOのフィールドには、第6弾で使った LocalizedString をそのまま持たせられます(公式ドキュメントにもSOでの使用例があります)。ただし、初期化完了前に同期取得するとnullになり得る点(第6弾で学んだ InitializationOperation の話)と、WebGLでは同期取得が使えない点に注意が必要です。

    画像

    本記事では第6弾の手記と同じく学習効率を優先して直書きとし、多言語化は腕試し課題とします。やり方は「string を LocalizedString に置き換えて、String Table にエントリを足す」石碑の改修と同じ手順の繰り返しです。


    第4章 一対の宝箱を組み立てる

    マテリアルを3つ用意する

    台帳ができたので、次は舞台です。キューブとスフィアだけで「目と銘板と飾りソケットを持つ宝箱」を組み立て、それを複製して一対にします。地味な作業に見えますが、第3弾(3D構築)・第5弾(Interactableレイヤー)・第6弾(3D TMPの鏡文字対策)の総復習になる章です。

    先にマテリアルを作っておくと、組み立てが一気に進みます。第4弾で作った Materials フォルダの中で、右クリック ▸ 作成 ▸ マテリアル を3回繰り返します。

    画像
    1. ChestMat(箱の木肌)
      Base Map の色:濃い茶色(例:R:110 G:75 B:45)

    2. EyeMat_Normal(ふだんの目)
      Base Map の色:ほぼ黒のこげ茶(例:R:35 G:25 B:20)
      放出(Emission):オフのまま

    3. EyeMat_Glow(光る目)
      Base Map の色:EyeMat_Normal と同じこげ茶。
      放出(Emission):オン。色は暖色のオレンジ、HDR強度 1.5 程度(第4弾のランタンで使った手順と同じです)

     EyeMat_Glow は、必ずインスペクターで放出をオンにして保存してください。
    「どうせスクリプトで光らせるんだから、オフのままでもいいのでは?」とも思いそうですが、実はそこに本記事の罠が潜んでいます(※ 詳しくは第7章の注意書きでじっくり解説します)。


    宝箱(ChestA)を組み立てる

    まず親を作り、その下に部品をぶら下げていきます。数値はすべて目安です。シーンビューで見ながら調整してください。

    ① 親オブジェクト

    画像
    1. ヒエラルキーで右クリック ▸ 空のオブジェクトを作成。名前を ChestPair に

    2. 位置は広間(TreasureHall)の中の空きスペースへ(例:床の上、Y:0)。( ※ 第6弾までと同じく、石碑・手記・名簿から3〜5mほど離してください。Interactorのレイは最初に当たった1つしか対象にしないため、近すぎると照準が競合します)

    3. ChestPair を右クリック ▸ 空のオブジェクトを作成。名前を ChestA に。位置 X:-1 / Y:0 / Z:0(ペアの中心から左へ1m)


    ② 本体とフタ

    画像
    1. ChestA を右クリック ▸ 3Dオブジェクト ▸ キューブ。名前を Body に。位置 (0, 0.3, 0)/スケール (0.8, 0.6, 0.5)

    2. ChestA を右クリック ▸ 3Dオブジェクト ▸ キューブ。名前を Lid に。位置 (0, 0.66, 0)/スケール (0.85, 0.12, 0.55)。本体より一回り大きい薄い板を、上に載せるイメージです

    3. Body と Lid の両方に ChestMat をマテリアル適用

    📝 Lid(フタ)に付いている Box Collider は削除しておきます(Lidを選択 → Box Colliderを右クリック ▸ コンポーネントを削除)。当たり判定はBody 1つに任せたほうが、レイの挙動が読みやすくなります。Bodyのコライダーはそのまま残します。


    ③ 目(スフィア×2)

    画像
    1. ChestA を右クリック ▸ 3Dオブジェクト ▸ スフィア。名前を EyeL に。スケール (0.1, 0.1, 0.1)、位置は本体の正面に少しめり込む程度(例:(-0.18, 0.42, -0.26))

    2. EyeL の Sphere Collider を削除(第4弾の LanternGlow と同じ手順。目にコライダーがあるとレイが目に吸われて紛らわしいためです)

    3. EyeL に EyeMat_Normal を適用

    4. EyeL を複製(Cmd+D)して EyeR に。位置Xを反転(例:0.18)

    ⚠️ 「正面」がどちらの面かはChestAの向き次第です。プレイヤーが歩いてくる側に目が付くよう、Zの符号(-0.26 ⇄ 0.26)で調整してください。第3弾の石碑刻文で学んだ「表裏の罠」と同じ考え方です。


    ④ 銘板(3D TMP)

    画像
    1. ChestA を右クリック ▸ 3Dオブジェクト ▸ テキスト - TextMeshPro。名前を PlateText に。※ UI(Canvas)の方ではない点に注意

    2. Font Asset に第6弾で作った日本語フォントアセット(NotoSansJP-Medium SDF)を割り当て(忘れると豆腐に戻ります)

    3. Font Size:12/Scale:(0.05, 0.05, 0.05)/Alignment:中央揃え(横・縦)

    4. 位置:本体正面の下寄り(例:(0, 0.15, -0.28))。テキストは仮で めざめのたから と入力

    5. ゲームビューで鏡文字になっていたら、回転Yを 180 に(第6弾の罠、再びです)


    ⑤ 飾りソケットと中身

    画像
    1. ChestA を右クリック ▸ 空のオブジェクトを作成。名前を PropSocket に。位置はフタの上(例:(0.25, 0.78, 0))。ここは「余計な飾り」が生える取り付け位置になります

    2. ChestA を右クリック ▸ 3Dオブジェクト ▸ スフィア。名前を Treasure に。スケール (0.25, 0.25, 0.25)、位置 (0, 0.45, 0)(本体の中)。Sphere Colliderは削除

    3. Treasure に、第4弾で作った LanternGlowMat を適用。開けたときに中身がふわっと光ります(過去の資産の再利用です)

    4. Treasure のインスペクター左上のチェックを外し、非アクティブにしておく(正解して開けるまで見えません)

    📝 非アクティブのオブジェクトでも、インスペクターでの参照割り当て(ドラッグ&ドロップ)は問題なくできます。「見えなくなった=割り当てられない」ではないので安心してください。


    ⑥ レイヤーを設定する

    画像
    1. ChestA(親)を選択し、インスペクター右上の Layer を Interactable に変更

    2. 「子オブジェクトのレイヤーも変更しますか?」というダイアログが出るので、「はい、子を変更する」を選ぶ

    これで、Bodyのコライダーが第5弾のInteractorのレイに拾われるようになります。

    💡 ここで第5弾の備えが効きます
     
    今回の構成は「コライダーは子(Body)、スクリプトは親(ChestA)」です。第5弾のInteractorは、当たったコライダーから GetComponentInParent<IInteractable>() で親をたどって探す作りにしてありました。「子にコライダー、親にスクリプトという構成に備えるため」と書いたあの1行が、ここでそのまま活きています。Interactor.cs は今回も1文字も書き換えません。


    飾りのPrefab(FakeOrnament)を作る

    「余計な飾り」としてくっつける小物を、Prefabにしておきます。

    画像
    1. ヒエラルキーで右クリック ▸ 3Dオブジェクト ▸ シリンダー。名前を FakeOrnament に

    2. スケール (0.06, 0.12, 0.06) の小さな柱にする。Capsule Collider は削除

    3. Materials フォルダに新規マテリアル OrnamentMat(明るい黄土色。例:R:210 G:170 B:80)を作って適用

    4. ⚠️ 位置と回転を (0, 0, 0) にリセットしてから、Assets/Prefabs フォルダへドラッグしてPrefab化

    5. シーンに残ったFakeOrnamentは削除

    画像

    第3章で保留にしていた Tell_Prop の Prop Prefab 欄に、このPrefabを割り当て。

    手順4の「位置リセット」には理由があります。
    第7章で Instantiate(propPrefab, propSocket) という親指定の2引数版で生成するのですが、この版は生成物を「親のローカル座標」として配置します(worldPositionStaysは false 扱い)。つまりPrefabに保存された位置が、そのままソケットからのズレになります。原点にしておけば「ソケットの位置にピタリと生える」わけです。ちなみに Transform.SetParent の2引数版は逆に worldPositionStays=trueが既定なので、混同にご注意を(地味な差分ポイントです)。


    複製してChestBを作る

    画像
    1. ヒエラルキーで ChestA を選択し、Cmd+D で複製

    2. 名前を ChestB に、位置を X:1(中心から右へ1m)に変更

    これで一対の宝箱が並びました。ゲーム開始「▶」で確認……と言いたいところですが、まだスクリプトが1本もアタッチされていないので、[E]プロンプトは出ません(IInteractableが居ないため、レイに当たってもInteractorが素通しします)。見た目が揃っていればこの章はOKです。


    宝箱を浮かせる

    画像

    記事を7章まで書いて、テストして判明したのですが、アバターの目線より宝箱が低いので、目線を下げないとレイが当たりません(「調べる」が出ない)
    これは面倒というか、ユーザーへの説明などもあります。

    よって、今回は宝箱を浮かします(「ChestPairのYを1.0」にするなど)。
    これにより、アバターの視線を下げなくても、「調べる」が出るようになります。

    📝先ほど説明した通り、実際に「調べる」が出るようになるのは、最後の方でアタッチした後になります。


    第5章 GameManager(簡易版)— 深度フェーズと失敗数

    FogControllerと同じ「型」で作る

    次は審判役です。ゲーム全体の状態、いま遺跡のどの深さにいるか(フェーズ)と、何回しくじったか(失敗数)を1か所で預かる GameManager を作ります。

    作りは、第4弾で作った FogController とまったく同じ「シーン内シングルトン」です。あのとき「シーンにただ1つ・どこからでも呼べる」仕組みとして学びました。同じ型なので、コードもすんなり読めるはずです。

    ■ GameManager.cs

    // ==========================================================
    // 📄 スクリプト:GameManager.cs(簡易版)
    // ==========================================================
    using UnityEngine;
    
    // ゲーム全体の進行状態を1か所で預かる管理役(今回は簡易版)。
    // 作りは第4弾のFogControllerと同じ「シーン内シングルトン」です。
    public class GameManager : MonoBehaviour
    {
        // どこからでも GameManager.Instance で呼べるようにする
        public static GameManager Instance { get; private set; }
    
        // 遺跡の深さを表すフェーズ。enumは「決まった選択肢から1つ選ぶ」型
        public enum DepthPhase
        {
            Shallow, // 浅層
            Middle,  // 中層
            Deep     // 深層
        }
    
        // [SerializeField]付きなので、Play中でもインスペクターから切り替えられる。
        // 迷路が完成する第8弾までは、これが「深さを変える手段」になります。
        [SerializeField] private DepthPhase currentPhase = DepthPhase.Shallow;
    
        // 外からは読み取り専用で公開(勝手に書き換えられないように)
        public DepthPhase CurrentPhase => currentPhase;
    
        // 失敗(偽物を開けてしまった)回数。今回は数えるだけ。
        // 第10弾でPlayerPrefsに保存するなど、本格的に使い倒します。
        public int MistakeCount { get; private set; }
    
        // 深層では判定が反転する(違和感のある方こそ本物)というルール
        public bool IsJudgementInverted => currentPhase == DepthPhase.Deep;
    
        // フェーズの日本語表示名。C#の「switch式」で書いています(後述📝)
        public string PhaseLabel => currentPhase switch
        {
            DepthPhase.Shallow => "浅層",
            DepthPhase.Middle  => "中層",
            DepthPhase.Deep    => "深層",
            _ => "不明" // どれにも当てはまらない場合の保険
        };
    
        private void Awake()
        {
            // シングルトンの基本形(第4弾のFogControllerと同じ):
            // 既に本物がいたら、後から現れた自分を消す
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }
            Instance = this;
        }
    
        // 失敗を1つ数える。外からはこのメソッド経由でしか増やせません
        public void AddMistake()
        {
            MistakeCount++;
        }
    }

    ※ Assets/Scripts内にGameManager.csを作成

    画像

    ヒエラルキーで右クリック ▸ 空のオブジェクトを作成 → 名前を GameManager にして、アタッチします(FogControllerオブジェクトの隣に置いておくと見つけやすいです)。

    ■ コードの読みどころ
    ● switch「式」という新顔

    画像
    値を返すだけなら「switch式」が圧倒的にスマート

     PhaseLabel のところで使っている currentPhase switch { ... => ... } は、比較的新しいC#の書き方(switch式)です。「値を1つ返すだけ」の分岐なら、従来のswitch文よりずっと短く書けます。従来のswitch文(caseとbreakで処理を書く形)は第6章の抽選機で使うので、両方の使い分けを体感できます

    ● DontDestroyOnLoadはまだ付けません

    画像
    必要になるまで「DontDestroyOnLoad」は使わない

     検索すると「GameManagerといえばDontDestroyOnLoad(シーンをまたいで生き残る)」という記事が多く出てきますが、あれは複数シーン構成のための仕組みです。現在シーンは1つなので不要。第10弾でタイトル画面などのシーン構成を作るときに、この簡易版を完成形へ育てます。必要になってから足すのが、シングルトンと安全に付き合うコツです。

    🧩 UiGate(静的クラス)との使い分け
     
    第6弾で作ったUiGateは public static bool IsOpen; だけの静的クラスでした。あちらはMonoBehaviourですらありません。使い分けの目安は「純粋なフラグ1個で、Unityの機能(インスペクター・Awake・シーン内の存在)が要らないなら静的クラス」「インスペクターで値を触りたい・Unityのライフサイクルに乗せたいならMonoBehaviourシングルトン」。今回はフェーズをインスペクターで切り替えたいので後者です。


    開発用HUDを作る(フェーズと失敗数の表示)

    GameManagerの中身は目に見えないので、画面の隅に現在値を表示する開発用HUDを作っておきます。動作確認が段違いに楽になります。

    ■ UIパーツの作成

    画像
    1. ヒエラルキーの Canvas(第5弾で作ったもの)を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前を PhaseDebugText に

    2. Font Asset に日本語フォントアセットを割り当て(毎回の合言葉です。忘れると豆腐)

    3. アンカープリセット:top left/位置 X:130・Y:-90(第6弾で左上に置いた言語ドロップダウンの、ちょうど下あたり)

    4. 幅 240/高さ 70/Font Size 20/Alignment:左揃え+上揃え/Vertex Color:白

    5. テキストは仮で フェーズ: - と入れておく(実行時にスクリプトが上書きします)

    ■ PhaseDebugHUD.cs

    // ==========================================================
    // 📄 スクリプト:PhaseDebugHUD.cs
    // ==========================================================
    using UnityEngine;
    using TMPro;
    
    // 現在のフェーズと失敗数を画面の隅に出しておく、開発用のHUD。
    public class PhaseDebugHUD : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI label; // 表示先のTMP
    
        void Update()
        {
            // GameManagerをシーンに置き忘れていても、エラーで止まらない保険
            if (GameManager.Instance == null) return;
    
            // $"..." は文字列補間。{ } の中の値が文字列に埋め込まれます
            label.text =
                $"フェーズ: {GameManager.Instance.PhaseLabel}\n" +
                $"失敗: {GameManager.Instance.MistakeCount}";
        }
    }

    ※ Assets/Scripts内にPhaseDebugHUD.csを作成

    画像

    PhaseDebugText 自身にアタッチします。インスペクターの Label 欄には、ヒエラルキーの PhaseDebugText を自分自身にドラッグ(第6弾のLanguageDropdownで使った「自分自身参照」と同じパターンです)。


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

    画像
    • 左上に「フェーズ: 浅層/失敗: 0」と表示される

    • Playモードのまま、ヒエラルキーの GameManager を選択し、インスペクターの Current Phase を Middleに変更 → HUDの表示が即座に「中層」へ変わる

    この「Play中にインスペクターでenumを切り替える」操作が、今回の動作確認の要になります。指に馴染ませておいてください。


    第6章 抽選機 — カタログから「今回の見分けポイント」を決める

    この章で作るものの位置づけ

    コードを書く前に、いま作ろうとしているものがゲームの中で何を決めているのかをはっきりさせておきます。

    今回の宝箱ギミックは、突き詰めればくじ引きです。プレイヤーが宝箱の前に立つたび、裏側では「今回はどの見分けポイントで化けさせるか」というくじが引かれています。そして、そのくじの中身の偏り方を決めているのが、これから作る抽選機です。

    ここで、混同しやすい2つの「調整」を分けて考えてください。


    画像
    第3章で実装したもの

    ① 出やすさ(重み)— 開発者だけが触る
    第3章で各アセットに入れた「浅層3・中層2・深層1」という数字です。これはゲームバランスそのもので、プレイヤーには見えませんし、変えることもできません。開発者がインスペクターで難易度カーブを設計する、いわば設定図面です。

    画像
    第5章で実装したもの

    ② いまどの段階か(フェーズ)— ゲーム進行で動く
    第5章でGameManagerに持たせたShallow / Middle / Deepです。同じ台帳を使っていても、フェーズが変われば抽選の傾向がまるごと変わります。浅層では分かりやすい「向きのズレ」が主力で「目の光」は出ない。深層では逆に「目の光」と「銘板の誤字」が主力になる——プレイヤーが進めば進むほど、見抜きにくいミミックに出会う、という難易度の設計です。

    📝 フェーズは、今の段階ではまだ手動です
     
    GameManagerのCurrent Phaseをインスペクターで切り替える運用で、ゲームの進行とは連動していません。ここが自動になるのは第8弾です。迷路を組んで「遺跡を奥へ進む=フェーズが上がる」と繋いだ瞬間、①の重みで設計した難易度カーブが、そのままプレイヤーの体験になります。今回はそのエンジン部分を先に作っておく回だと思ってください。

    画像
    今後の「第7章」で実装予定

    なお、プレイヤーが難易度を選ぶ設定画面のようなものは、今回は作りません。プレイヤーにできるのは「どちらの箱を開けるか」の判断だけです。ただ、もし将来イージー/ハードのような選択肢を入れたくなっても、抽選機のコードには一切手を入れずに実現できます(重みをもう1組足すか、フェーズの進み方を変えるだけ)。これもデータとロジックを分けておいた恩恵です。


    抽選機の要件を整理する

    台帳(4つのSO)と審判(GameManager)が揃ったので、両者を繋ぐ抽選機を作ります。要件は3つです。

    画像
    抽選機(TellLottery)の3つの要件
    1. フェーズ別の重みに従う
      浅層では「向きのズレ」が出やすく、「目の光」は出ない(重み0)

    2. 直近に出たものは出さない
      同じ見分けポイントが連続すると単調なので、直近2件は除外する

    3. 必ず1件返す
      除外しすぎて候補が空になっても、破綻せずに何かを返す

    ②の「直近を覚えておく」に使うのが Queue(キュー) です。

    📝 Queueとは?
    先に入れたものから先に出ていく「行列」型のコレクションです(First In, First Out)。Enqueue で行列の最後尾に追加、Dequeue で先頭から取り出し、Contains で「行列に並んでいるか」を確認できます。「直近N件だけ覚えておき、古いものから忘れる」という用途にぴったりの構造です。


    重み付き抽選(累積和方式)の考え方

    ①の「重みに従って選ぶ」には、定番の累積和方式を使います。仕組みは福引きの数直線で考えると分かりやすいです。

    画像
    重みを「数直線の幅(領土)」として並べ、乱数の矢を一本撃ち込むだけのシンプルで確実なアルゴリズム。

    たとえば候補が「向きのズレ(重み3)・飾り(重み2)・誤字(重み1)」なら、合計は6。0〜6の数直線を「0〜3は向きのズレ」「3〜5は飾り」「5〜6は誤字」と重みの幅で区切り、0〜6の乱数を1つ引いて『矢が刺さった区間の持ち主が当選』これだけです。重みが大きいほど区間が広い=当たりやすい、が自然に実現できます。

    ⚠️ ここでRandom.Rangeの意外な仕様が効いてきます
     
    Unityの Random.Range は、int版は上限を含まない(Random.Range(0, 10) は0〜9)のに、float版は上限を含む(Random.Range(0f, 6f) は6.0ちょうども返り得る)という非対称な仕様です(公式リファレンスに明記されています)。つまり累積和方式では、ごく稀に「乱数=合計値ぴったり」が引かれ、r < cumulative の判定をすべてすり抜けてしまう可能性があります。ループを抜けたら最後の候補を返す「フォールバック」を必ず用意する——これが重み付き抽選のお作法です。


    ギミック抽選機(TellLottery.cs)

    // ==========================================================
    // 📄 スクリプト:TellLottery.cs(抽選機)
    // ==========================================================
    using System.Collections.Generic;
    using UnityEngine;
    
    // カタログ(見分けポイントSOの一覧)から、現在のフェーズに応じて
    // 1件を重み付きで抽選する装置。直近に出たものは履歴Queueで除外します。
    public class TellLottery : MonoBehaviour
    {
        [Header("カタログ(見分けポイントのSOアセットを登録)")]
        [SerializeField] private List<MimicTellData> catalog = new List<MimicTellData>();
    
        [Header("直近何件を「出さない」ようにするか")]
        [SerializeField] private int historySize = 2;
    
        // 直近に選ばれたものを、古い順に覚えておく行列(FIFO)
        private readonly Queue<MimicTellData> recentHistory = new Queue<MimicTellData>();
    
        // あるデータの、現在フェーズでの重みを返す。
        // こちらは従来のswitch「文」。caseごとに処理を書いてreturnで抜けます
        private int GetWeight(MimicTellData data, GameManager.DepthPhase phase)
        {
            switch (phase)
            {
                case GameManager.DepthPhase.Shallow: return data.weightShallow;
                case GameManager.DepthPhase.Middle:  return data.weightMiddle;
                case GameManager.DepthPhase.Deep:    return data.weightDeep;
                default: return 0;
            }
        }
    
        // フェーズを受け取り、見分けポイントを1件抽選して返す(今回の主役メソッド)
        public MimicTellData Pick(GameManager.DepthPhase phase)
        {
            // ① 候補を絞る:重みが1以上 かつ 直近履歴に入っていないもの
            List<MimicTellData> candidates = new List<MimicTellData>();
            foreach (var data in catalog)
            {
                if (GetWeight(data, phase) > 0 && !recentHistory.Contains(data))
                    candidates.Add(data);
            }
    
            // ② 全部が履歴で弾かれてしまったら、履歴を無視してもう一度絞る(保険その1)
            //    例:候補が2種類しか無いフェーズで履歴サイズが2だと、①は毎回空になる
            if (candidates.Count == 0)
            {
                foreach (var data in catalog)
                {
                    if (GetWeight(data, phase) > 0)
                        candidates.Add(data);
                }
            }
    
            // ③ それでも0件=このフェーズに出せるデータが1つも無い(台帳の設定ミス)
            if (candidates.Count == 0)
            {
                Debug.LogWarning("TellLottery: このフェーズで出せる見分けポイントがありません");
                return null;
            }
    
            // ④ 重み付き抽選(累積和方式)で1件選ぶ
            MimicTellData picked = PickWeighted(candidates, phase);
    
            // ⑤ 履歴に記録し、あふれたぶんは古い順に忘れる(固定長キューの定石)
            recentHistory.Enqueue(picked);
            while (recentHistory.Count > historySize)
                recentHistory.Dequeue();
    
            return picked;
        }
    
        // 重み付き抽選の本体(累積和方式)
        private MimicTellData PickWeighted(List<MimicTellData> candidates,
                                           GameManager.DepthPhase phase)
        {
            // 重みの合計=数直線の全長を出す
            int totalWeight = 0;
            foreach (var data in candidates)
                totalWeight += GetWeight(data, phase);
    
            // 0〜合計の乱数を1本引く。
            // ⚠️ float版のRandom.Rangeは「上限も含む」仕様(下の解説参照)
            float r = Random.Range(0f, totalWeight);
    
            // 数直線を重みの幅で区切りながら、乱数が入った区間の持ち主を探す
            float cumulative = 0f;
            foreach (var data in candidates)
            {
                cumulative += GetWeight(data, phase);
                if (r < cumulative)
                    return data;
            }
    
            // 乱数が上限ぴったりだった等、判定をすり抜けたときの保険その2。
            // 最後の候補を返す「フォールバック」を必ず置いておきます
            return candidates[candidates.Count - 1];
        }
    
        // ⋮メニューから実行できる抽選テスト。Play中に使います(詳細は本文)
        [ContextMenu("抽選テスト(10回)")]
        private void DebugPick10()
        {
            if (GameManager.Instance == null)
            {
                Debug.LogWarning("Playモードで実行してください(GameManagerが必要です)");
                return;
            }
            for (int i = 0; i < 10; i++)
            {
                var tell = Pick(GameManager.Instance.CurrentPhase);
                Debug.Log($"抽選{i + 1}回目: {(tell != null ? tell.tellName : "なし")}");
            }
        }
    }

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

    画像

    ヒエラルキーで右クリック ▸ 空のオブジェクトを作成 → 名前を TellLottery にしてアタッチします。

    📝 抽選機をChestPairの中ではなく独立したオブジェクトにしたのには理由があります。履歴(直近に何が出たか)は仕掛け全体で共有したい情報だからです。第8弾で迷路に宝箱ペアを量産したとき、複数のペアがこの1台を共有すれば「隣の分岐と同じ見分けポイントが連発する」のを自然に防げます。ただしシングルトンにはしません。呼びたいのはChestPairControllerだけなので、インスペクターの参照割り当てで十分。第4弾で学んだ「シングルトンは乱用厳禁」の実践です。


    ■ インスペクターでカタログを登録する

    画像
    1. ヒエラルキーで TellLottery を選択

    2. Tell Lottery (スクリプト) の Catalog の ▶ を展開

    3. 右下の「+」を4回押す(またはサイズ欄に 4 と入力)

    4. 各要素の ⊙ ボタン → 一覧から Tell_Rotation / Tell_EyeGlow / Tell_Prop / Tell_PlateText を1つずつ選択(ProjectウィンドウのDataフォルダからまとめてドラッグしてもOKです)

    5. History Size は 2 のまま

    リストの要素は、左端の「=」マークをドラッグすると並び替えられます。今回は順不同で構いません。

    🧩 コラム:Inspectorが壊れる既知バグ(踏んだときのために)
    Unity 6系の一部バージョン(6000.0.58f1や6000.2.5f1〜2.6f1)では、List/配列を持つコンポーネントを選択するとInspectorの描画が壊れ、「UnityException: GetName can only be called from the main thread」というエラーが出る既知バグが報告されていました。6.5系では基本的に踏まない見込みですが、もし遭遇したら「Preferences ▸ General ▸ Editor Font を System Font に変更」または「プロジェクト設定 ▸ エディター ▸ Use IMGUI Default Inspector をオン」で回避できます。


    ContextMenuで抽選テスト

    コードの末尾に付けた [ContextMenu] という属性、今回の新顔です。これを付けたメソッドは、インスペクターのコンポーネント名を右クリック(または右上の⋮)したメニューから直接実行できます。動作確認用の小さなテストを仕込むのに便利な機能です。

    画像
    1. ゲーム開始「▶」(GameManagerが必要なのでPlay中に行います)

    2. ヒエラルキーで TellLottery を選択

    3. インスペクターの Tell Lottery (スクリプト) のヘッダー部分を右クリック ▸ 「抽選テスト(10回)」

    4. Consoleに抽選結果が10件流れる

    確認ポイントは2つです。

    • 浅層(初期状態)では「目の光」が1度も出ないこと(重み0=絞り込みが効いている)

    • 同じ名前が3連続しないこと(履歴サイズ2=直近2件は除外されている)

    続けて、Play中のまま GameManager の Current Phase を Deep に変えて、もう一度テストを実行してみてください。今度は「目の光」「銘板の誤字」が多めに出るはずです。重みの数字がそのまま手触りになる。これがデータ駆動の気持ちよさです。

    📝 System.RandomではなくUnityEngine.Randomを使う理由。
    C#標準にも System.Random がありますが、Unityでは UnityEngine.Random が公式に推奨されています(公式リファレンス曰く、同等機能で20〜40%高速)。ちなみに両方の名前空間を using すると「Random」がどちらか曖昧だというコンパイルエラー(CS0104)になります。その場合は using Random = UnityEngine.Random; と書いて解決するのが定石です。

    🧩 コラム:抽選結果を「再現」したいとき(シード固定)
    「さっきのバグ、抽選が特定の順で出たときだけ起きる気がする……」というとき、Random.InitState(12345) のようにシード(乱数の種)を固定すると、毎回まったく同じ抽選列を再現できます。ただしUnityの乱数は全スクリプト共通のグローバルな状態なので、これを仕込むと他の場所の乱数まで固定されます。デバッグが済んだら必ず外してください。

    🧩 コラム:もう1つの定番
    「シャッフルバッグ」 重複を防ぐ別解として、「袋に全候補を入れてシャッフルし、引き切るまで補充しない」シャッフルバッグ方式もあります。全員に必ず1回ずつ出番が回るのが特徴で、ビンゴやカードゲームに向きます。今回のように「重み(出やすさの差)を付けたい」場合は累積和方式、「均等に一巡させたい」場合はシャッフルバッグ、と使い分けると良いでしょう。


    第7章 適用と判定 — 調べて、見抜いて、開ける

    3つのスクリプトの設計

    いよいよ全部を繋ぎます。この章で書くスクリプトは3本。

    画像

    「判断する者」と「言われたことをやる者」を分離する設計です。

    宝箱や結果表示UIは、互いの存在も、判定のルールも知りません。すべてが進行役(ChestPairController)を経由しています。

    💡 この設計の最大のメリットは「変更への強さ」
    将来「判定のルールを変えたい」と思ったら、進行役のスクリプトを1か所だけ直せば済みます。宝箱のスクリプトやUIのスクリプトを触って壊してしまうリスクはゼロになります。


    ■ TreasureChest.cs(宝箱・2つ)

    画像

    宝箱1個ぶんの実働部隊。役割は2つで、第5弾の IInteractable を実装して「[E] 開ける」の窓口になること(Interact() で進行役へ報告)と、進行役の指示で自分の見た目を変えること(角度・目の色・飾り・銘板を切り替え)。判定のルールは一切持たず、報告と実行に徹します。


    ■ ChestPairController.cs(進行役・司令塔)

    画像

    一対の宝箱を束ねる中心。「抽選する→片方に差分を付ける→開けられた箱を判定する→リセットして次へ」というゲームの1周を回します。判定は Judge() の中で pickedHasTell ^ inverted というXOR 1行に集約。深層では判定が反転する、という今回のルールの心臓部です。


    ■ JudgeFeedbackUI.cs(結果表示UI)

    画像

    進行役から「正解・不正解」を受け取り、画面に文字をふわっと出して数秒で消す演出係。SetActive のオンオフではなく CanvasGroup の alpha で制御することで、第4弾で学んだ「フェードアウト中にコルーチンが止まる」問題を構造的に避けています。

    長い章ですが、1本ずつ「読みどころ」を挟みながら進みます。


    TreasureChest.cs — 宝箱の「見た目係」兼「調べられる口」

    宝箱1個ぶんのスクリプトです。

    役割は2つ。第5弾で決めた共通の約束 IInteractable を実装して「[E] 開ける」に応える口になること。そして、進行役の指示で自分の見た目(向き・目・飾り・銘板)を変える見た目係になることです。

    // ==========================================================
    // 📄 スクリプト:TreasureChest.cs
    // ==========================================================
    using UnityEngine;
    using TMPro;
    
    // 宝箱1個ぶんの「見た目の操作」と「調べられる口(IInteractable)」を担当。
    // どちらが本物か、という判定はChestPairControllerに任せます。
    public class TreasureChest : MonoBehaviour, IInteractable
    {
        [SerializeField] private string prompt = "開ける"; // "[E] 開ける" に使われる
    
        [Header("部品の参照")]
        [SerializeField] private GameObject lid;          // フタ
        [SerializeField] private GameObject treasure;     // 中身(初期非表示)
        [SerializeField] private Renderer[] eyeRenderers; // 目(左右2つ)
        [SerializeField] private TextMeshPro plateText;   // 銘板(3D TMP)
        [SerializeField] private Transform propSocket;    // 飾りの取り付け位置
    
        [Header("目のマテリアル(アセットを2つ割り当て)")]
        [SerializeField] private Material eyeNormalMat;   // ふだんの目
        [SerializeField] private Material eyeGlowMat;     // 光る目(放出オン済み)
    
        [Header("ペアの進行役")]
        [SerializeField] private ChestPairController pairController;
    
        private float baseRotationY;    // 最初の向きを覚えておくメモ
        private GameObject spawnedProp; // Instantiateした飾り(後片付け用)
    
        private void Awake()
        {
            baseRotationY = transform.localEulerAngles.y; // 初期の向きを記憶
        }
    
        // ---- IInteractable(第5弾で決めた共通の約束)----
    
        public string GetPrompt() => prompt; // Interactorが "[E] {これ}" に使う
    
        public void Interact()
        {
            pairController.Judge(this); // 「私が開けられました」と進行役へ報告
        }
    
        // ---- ここから下は、進行役から呼ばれる「見た目の操作」----
    
        // 差分なしのまっさらな状態へ戻す
        public void ResetView(string basePlate)
        {
            // 向きを初期値へ(localEulerAnglesで角度を「絶対指定」しています)
            transform.localEulerAngles = new Vector3(0f, baseRotationY, 0f);
            SetEyesGlow(false);          // 目を消す
            plateText.text = basePlate;  // 銘板を基本の文言に
            lid.SetActive(true);         // フタを閉じる
            treasure.SetActive(false);   // 中身を隠す
    
            if (spawnedProp != null)     // 前回の飾りが残っていたら
            {
                Destroy(spawnedProp);    // 破棄して片付ける
                spawnedProp = null;
            }
        }
    
        // 見分けポイントを自分に適用する(=この箱が「違和感のある方」になる)
        public void ApplyTell(MimicTellData tell, string basePlate)
        {
            switch (tell.tellType)
            {
                case TellType.Rotation:
                    // 初期の向きに、SOで指定されたぶんのズレを足す
                    transform.localEulerAngles =
                        new Vector3(0f, baseRotationY + tell.rotationOffsetY, 0f);
                    break;
    
                case TellType.EyeGlow:
                    SetEyesGlow(true); // 目を光らせる
                    break;
    
                case TellType.Prop:
                    if (tell.propPrefab != null)
                    {
                        // 親(ソケット)を指定してInstantiate。
                        // この2引数版は生成物を「親のローカル座標」で置きます。
                        // Prefab側の位置を(0,0,0)にしておいたのはこのためです
                        spawnedProp = Instantiate(tell.propPrefab, propSocket);
                    }
                    break;
    
                case TellType.PlateText:
                    // 1文字違いの文言に差し替え。textへの代入だけで、
                    // 次の描画から自動で反映されます(ForceMeshUpdateは不要)
                    plateText.text = tell.fakePlateText;
                    break;
            }
        }
    
        // 正解演出:フタを外して中身を見せる(簡易版。演出の作り込みは第11弾で)
        public void Open()
        {
            lid.SetActive(false);
            treasure.SetActive(true);
        }
    
        // 目のマテリアルをまるごと「差し替える」。
        // sharedMaterialへの差し替え(参照の付け替え)なので安全です。
        // ⚠️「差し替え」と「書き換え」の違いは、この後の⚠️と🧩を必ず読んでください
        private void SetEyesGlow(bool on)
        {
            foreach (var r in eyeRenderers)
            {
                r.sharedMaterial = on ? eyeGlowMat : eyeNormalMat;
            }
        }
    }

    Assets/Scripts内にTreasureChest.csを作成します(アタッチと参照割り当ては、3本すべて書いてからまとめて行います)。


    なぜ「EnableKeyword」で光らせないのか?

    さて、第4章から引っ張ってきた宿題を回収します。目を光らせる処理を、なぜ第4弾のランタンのように書かなかったのか?という話です。

    第4弾のLanternControllerでは、Emissionのオン/オフをこう書いていました。

    // 第4弾のランタンの方式(キーワード切り替え方式)
    glowMaterial.EnableKeyword("_EMISSION");
    glowMaterial.SetColor("_EmissionColor", Color.white * 1.5f);

    同じ方式で目も光らせられそうなものです。実際、エディタ上では問題なく光ります。ところが、この方式には条件が揃うと発動する恐ろしい罠があります。ビルドすると光らなくなるのです。

    画像
    対策:発光オン済みのマテリアルを用意し「差し替え」で切り替える

    🧩 仕組み:シェーダーバリアントのストリッピング
    URPの標準Litシェーダーの中で、Emissionは「_EMISSION」というキーワードとして宣言されています。そしてこの宣言は「マテリアル用のキーワード(shader_feature)」という種類で、ビルド時に、プロジェクト内のどのマテリアルアセットもそのキーワードを有効化していなければ、対応する処理(バリアント)がビルドから丸ごと除去される仕様です。ビルドを軽くするための最適化なのですが、「スクリプトから実行時にオンにするつもり」はビルド解析には伝わりません。 さらにたちが悪いことに、除去済みのバリアントを実行時に要求してもエラーは出ません。Unityは「いちばん近い別のバリアント」で黙って代替描画します。結果、「エラーも警告もないのに、ビルドだけ光らない」——第4弾の「エディタでは霧が出るのにビルドで消える」(Fog Modesのストリッピング)と、まったく同型の罠です。


    ■ では、なぜ第4弾のランタンは無事なのか?

    思い出してください。第4弾では、LanternGlowMatを作るときにインスペクターで「放出(Emission)」をオンにして保存しました。マテリアルアセットに_EMISSIONキーワードが記録されているので、ビルドにも発光バリアントがちゃんと同梱されます。あの手順が、知らないうちに罠除けになっていたわけです。


    ■ 対策の定石

    画像
    1. 発光オン済みのマテリアルアセットを最初から用意し、「差し替え」で切り替える(今回の方式)。EyeMat_Glowはインスペクターで放出オン済みなので、バリアントは確実にビルドへ入ります。キーワード操作そのものをしないので、罠の射程外です

    2. キーワード方式を使うなら、少なくとも1つのマテリアルアセットで対象機能をオンにしておく(第4弾ランタンが結果的にこれ)

    3. さらに本格的には Shader Variant Collection という仕組みでバリアントを明示登録する方法もあります(今回は不要なので名前だけ)

    💡 「ウチのプロジェクト、実は踏んでない?」を確かめたいときは、プロジェクト設定 ▸ プレイヤー にある Strict Shader Variant Matching をオンにしてみてください。無音の代替描画がエラーとして可視化されます(普段はオフで構いません)。この種のストリッピング問題は第12弾のビルド前チェックで、フォグと合わせてまとめて再掲します。


    sharedMaterial は「差し替え」ならOK、「書き換え」はNGとなります。

    画像
    sharedMaterial は「差し替え」ならOK、「書き換え」はNG

    SetEyesGlowでやっている r.sharedMaterial = 別のマテリアル; は、参照の付け替えです。マテリアルの中身には触れないので安全です。

    危ないのは r.sharedMaterial.color = ... のような中身の書き換え。sharedMaterialはアセット本体を指すので、Play中に書き換えるとアセットが恒久的に変わり、Play終了後も戻りません(Ctrl+Zも効かない)。第3章のSOの罠と同じ「エディタでは書き込みが本当に書き込まれる」性質です。

    • 中身を書き換えたい → material(自分専用のコピー。Play終了で戻る)

    • 丸ごと差し替えたい → sharedMaterial(今回の目の光がこれ)

    覚え方はこれだけです。

    📝 ちなみに renderer.materials[0].color = ... と配列経由で書いても効きません。materialsは配列のコピーを返すためです(変えるなら配列を変数に受けて、代入し直します)。


    ChestPairController.cs — 進行役と「XOR判定」

    次はペア全体の進行役です。抽選 → どちらに差分を付けるか決定 → 適用 → 判定 → 数秒後にリセットして次の抽選、というループを回します。

    // ==========================================================
    // 📄 スクリプト:ChestPairController.cs
    // ==========================================================
    using System.Collections;
    using UnityEngine;
    
    // 一対の宝箱を管理する進行役。
    // 抽選→差分の適用→判定→フィードバック→リセット、の流れを回します。
    public class ChestPairController : MonoBehaviour
    {
        [Header("一対の宝箱")]
        [SerializeField] private TreasureChest chestA;
        [SerializeField] private TreasureChest chestB;
    
        [Header("参照")]
        [SerializeField] private TellLottery lottery;        // 抽選機
        [SerializeField] private JudgeFeedbackUI feedbackUI; // 結果表示UI
    
        [Header("設定")]
        [SerializeField] private string basePlateText = "めざめのたから"; // 銘板の基本文言
        [SerializeField] private float resetDelay = 3f;     // 判定後、何秒でやり直すか
    
        private TreasureChest chestWithTell; // 今回「違和感」が付いている方
        private MimicTellData currentTell;   // 今回の見分けポイント
        private bool isResolving;            // 判定演出中フラグ(連打・二重判定の防止)
    
        private void Start()
        {
            Setup(); // 最初の1回をセットアップ
        }
    
        // 抽選して、ペアに差分を適用する
        private void Setup()
        {
            // まず両方をまっさらな状態へ
            chestA.ResetView(basePlateText);
            chestB.ResetView(basePlateText);
    
            // 現在のフェーズで抽選
            currentTell = lottery.Pick(GameManager.Instance.CurrentPhase);
            if (currentTell == null) return; // 出せるデータが無ければ何もしない
    
            // どちらに差分を付けるかをコイントスで決める。
            // Random.valueは0.0〜1.0(両端を含む)のfloatを返します。
            // 「< 0.5f」でほぼ50:50の二択になります
            chestWithTell = (Random.value < 0.5f) ? chestA : chestB;
            chestWithTell.ApplyTell(currentTell, basePlateText);
        }
    
        // 宝箱側から「開けられました」と報告が来る窓口
        public void Judge(TreasureChest picked)
        {
            if (isResolving) return; // 判定演出中は受け付けない(連打・二重判定の防止)
            isResolving = true;
    
            // ① 開けたのは「違和感のある方」か?
            bool pickedHasTell = (picked == chestWithTell);
    
            // ② いまは判定が反転するフェーズ(深層)か?
            bool inverted = GameManager.Instance.IsJudgementInverted;
    
            // ③ XOR(排他的論理和)1発で「偽物を開けてしまったか」が決まります。
            //    ^ は「2つのboolが異なるときだけtrue」になる演算子(真理値表は本文)
            bool pickedIsFake = pickedHasTell ^ inverted;
    
            if (!pickedIsFake)
            {
                picked.Open();                               // 正解:開封!
                feedbackUI.Show(true, currentTell.tellName);
            }
            else
            {
                GameManager.Instance.AddMistake();           // 不正解:失敗を1カウント
                feedbackUI.Show(false, currentTell.tellName);
            }
    
            StartCoroutine(ResetRoutine()); // 数秒後にやり直し
        }
    
        // 判定の余韻→リセット→受付再開、までを担当するコルーチン
        private IEnumerator ResetRoutine()
        {
            yield return new WaitForSeconds(resetDelay);
            Setup();             // 次の抽選&適用
            isResolving = false; // 判定の受け付けを再開
        }
    }

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

    📝 ResetRoutineはJudge以外から起動されず、Judgeの入口が isResolving で施錠されているので、第4弾で学んだ「コルーチンの多重起動」は構造的に起きません。多重起動防止は「StopCoroutineで止める」だけでなく、こうして入口を1つに絞る設計でも実現できます。


    ■ XOR(排他的論理和)を真理値表で腹落ちさせる
    Judgeの③で使った ^(XOR)が、今回のロジックの華です。

    XORは「2つのboolが異なるときだけtrue」になる演算子です。ゲームのルールと突き合わせてみましょう。

    • 通常(浅層・中層):違和感のある方=偽物。「違和感のある方を開けた(true)」なら偽物を開けた(true)

    • 深層(反転):作りが完璧すぎる方こそ怪しく、違和感(ほころび)のある方が本物。「違和感のある方を開けた(true)」なら本物を開けた=偽物ではない(false)

    これを表にすると

    画像
    bool pickedIsFake = pickedHasTell ^ inverted;

    「2つが異なるときだけtrue」まさにXORの定義そのままが、「深層では判定が裏返る」というゲームルールになっています。if文の入れ子で書くと4分岐8行になるところが「pickedHasTell ^ inverted」の1行で済むわけです。

    📝 実はC#では、bool同士の ^ は !=(不等号)とまったく同じ結果になります(Microsoftの公式リファレンスに明記があります)。pickedHasTell != inverted と書いても動作は同一です。ただ、「値が違うか比べたい」のではなく「条件を反転させるスイッチ」という意図を表したい場面では、XORの ^ で書くほうが設計の気持ちが伝わります。本連載では意図重視で ^ を採用しました。


    なぜ、わざわざ判定を反転させるのか?

    「深層だけルールが裏返るなんて、プレイヤーに不親切では?」と思うかもしれません。反転させる狙いは2つあります。

    画像
    同じルールを裏切ることで、単純な間違い探しが「考えるゲーム」に変わる
    1. 緊張感を取り戻すため
      プレイヤーは浅層で「違和感のある方を避ければいい」とすぐ学習します。同じルールが最後まで続くと、後半はただの作業になって飽きてしまう。深層で一度ルールをひっくり返すと、「これまでの正解パターンが通用しない」瞬間が生まれ、手が止まります。学習したことをあえて裏切るのは、難易度を上げる定番の手口です。

    2. 考える〜手間を挟むため
      反転がなければ、違和感を探すだけの反射ゲームです。反転があると「今は通常か、反転か」をまず思い出す必要が生まれ、判断が二段階になります。単純な間違い探しが、少しだけ頭を使うゲームに変わります。

    📝 そして正直に言えば、この反転仕様はXORが最も輝く題材だからでもあります。「基本の判定」と「反転フラグ」をXORすれば、pickedHasTell ^ inverted のたった1行で通常・反転の両方を表現できる。もし反転がなければ、この気持ちよさをお見せする場面がありませんでした。ゲームの都合と、学びの都合が、うまく噛み合った仕掛けなのです。

    ⚠️ なお、遊びとして詰めるなら「深層に入る前に掟が変わる予告を出す」といった親切設計も考えられます。本記事では仕組みの学習を優先してシンプルな即時反転にしていますが、実際のゲームバランスは調整の余地がある、と頭の片隅に置いておいてください。


    JudgeFeedbackUI — CanvasGroupでふわっと出るUI

    最後のパーツ、結果表示UIです。「せいかい!/はずれ……」を1.5秒ほど表示して、ふわっと消える。それだけのUIですが、新しい道具 CanvasGroup を使って、これまでのUIとはひと味違う作りにします。

    ■ なぜSetActiveのオン/オフではダメなのか

    画像

    LoreUIやNoteUIでは panelRoot.SetActive(true/false) でパネルを出し入れしていました。今回それをやると困ったことになります。第4弾で学んだとおり、GameObjectを非アクティブにすると、その上のコルーチンは止まるからです。「フェードアウトのコルーチンの最後で自分をSetActive(false)にする」ような作りは、消した瞬間に次のフェード処理が動かせなくなり、設計がねじれます。

    画像

    そこで CanvasGroup です。パネルに付けると、次の3つをまとめて制御できます。

    • Alpha:配下のUI全体の透明度(0で完全に見えない)

    • Interactable:配下のボタン等を操作できるか

    • Blocks Raycasts:配下がマウスのクリックを遮るか

    つまり「GameObjectはずっとアクティブなまま、alphaで見えなくする」という運用ができます。オブジェクトが生きているのでコルーチンは止まらず、フェード演出と相性抜群です。


    ■ UIパーツの作成

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

    2. Rect Transform のアンカープリセットで middle center を選び、位置 X:0/Y:120、幅 520/高さ 130(画面中央のやや上に浮かぶ帯になります)

    3. JudgePanel の子に UI (Canvas) ▸ テキスト - TextMeshPro を作成。名前を JudgeText に

    4. JudgeText:アンカープリセットでストレッチ(全方向)、Left/Right/Top/Bottom を 15。Font Asset に日本語フォント、Font Size 28、Alignment 中央揃え(横・縦)

    5. JudgePanel を選択し、「コンポーネントを追加」→ Canvas Group を検索して追加

    「手順5(Canvas Group追加)」について、動作上は手順2の直後でも問題ありませんが、この順序(子を作り終えてから最後に付ける)を推奨します。Canvas Groupは配下のUIをまとめて制御する『まとめ役』なので、中身(JudgeText)が揃ってから乗せるほうが役割が理解しやすいためです。また、先に付けてalpha=0を設定すると、子を作る作業中ずっとパネルが透明になり「JudgeTextが見えない」と混乱しかねません。箱を組み立ててから最後にフタ役を乗せる、と考えてください。

    📝 パネルの背景色はお好みで。インスペクターの Image の色を、黒の半透明(A:180程度)にすると文字が読みやすくなります。


    ■ JudgeFeedbackUI.cs

    // ==========================================================
    // 📄 スクリプト:JudgeFeedbackUI.cs
    // ==========================================================
    using System.Collections;
    using UnityEngine;
    using TMPro;
    
    // 正解/不正解の結果を、ふわっと表示してふわっと消すUI。
    // パネルは常にアクティブのまま、CanvasGroupのalpha(透明度)だけを動かします。
    public class JudgeFeedbackUI : MonoBehaviour
    {
        [SerializeField] private CanvasGroup canvasGroup;   // パネルのCanvasGroup
        [SerializeField] private TextMeshProUGUI label;     // 結果の文字
        [SerializeField] private float fadeDuration = 0.3f; // フェードにかける秒数
        [SerializeField] private float holdDuration = 1.8f; // 表示を保つ秒数
    
        private Coroutine routine; // 走行中の演出を覚えておく(多重起動防止の定石)
    
        private void Awake()
        {
            canvasGroup.alpha = 0f;             // 最初は見えない(オブジェクトは生きている)
            canvasGroup.interactable = false;   // 操作対象にしない
            canvasGroup.blocksRaycasts = false; // クリックも素通しにする(表示専用のUIなので)
        }
    
        public void Show(bool correct, string tellName)
        {
            // 見分けポイントの名前も一緒に出すと、外したときの学びになります
            label.text = correct
                ? $"せいかい!\n(見分けポイント:{tellName})"
                : $"はずれ……\n(見分けポイント:{tellName})";
    
            if (routine != null) StopCoroutine(routine); // 走っていたら止めてから
            routine = StartCoroutine(FadeRoutine());     // 新しく開始(第4弾の定石)
        }
    
        private IEnumerator FadeRoutine()
        {
            yield return Fade(0f, 1f);                     // フェードイン
            yield return new WaitForSeconds(holdDuration); // しばらく表示
            yield return Fade(1f, 0f);                     // フェードアウト
            routine = null;
        }
    
        // alphaをfromからtoへ、fadeDuration秒かけてなめらかに動かす。
        // 「timeを足す→tを求める→Mathf.Lerp→yield return null」……
        // 第4弾のFogControllerとまったく同じ骨格です
        private IEnumerator Fade(float from, float to)
        {
            float time = 0f;
            while (time < fadeDuration)
            {
                time += Time.deltaTime;
                float t = time / fadeDuration;
                canvasGroup.alpha = Mathf.Lerp(from, to, t);
                yield return null;
            }
            canvasGroup.alpha = to; // 誤差をなくすため最後にきっちり目標値へ
        }
    }

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

    画像
    • Canvas Group:JudgePanel 自身をドラッグ(付けたCanvas Groupが入ります)

    • Label:JudgeText

    📝 yield return Fade(0f, 1f); という書き方も今回の新顔です。コルーチンの中から別のコルーチンを呼び、その完了を待つことができます。「フェードイン→待つ→フェードアウト」という進行台本(FadeRoutine)と、フェードの実務(Fade)を分けられるので、コードが読みやすくなります。


    すべてを繋ぐ(参照の割り当て)

    スクリプトが3本揃いました。アタッチと参照割り当てを一気に済ませます。

    ① ChestA / ChestB に TreasureChest.cs をアタッチし、それぞれ以下を割り当て(2箱とも同じ作業です)

    画像
    ※「ChestB」も同様に行うこと
    • Lid:自分の子の Lid

    • Treasure:自分の子の Treasure(非アクティブでも割り当て可能です)

    • Eye Renderers:サイズ 2 にして、EyeL と EyeR

    • Plate Text:自分の子の PlateText

    • Prop Socket:自分の子の PropSocket

    • Eye Normal Mat:ProjectのEyeMat_Normal / Eye Glow Mat:EyeMat_Glow

    • Pair Controller:ヒエラルキーの ChestPair(⚠️ 次でスクリプトを付けた後でやる)


    ② ChestPair に ChestPairController.cs をアタッチし、以下を割り当て

    画像
    • Chest A:ChestA / Chest B:ChestB

    • Lottery:ヒエラルキーの TellLottery

    • Feedback UI:ヒエラルキーの JudgePanel

    • Base Plate Text:めざめのたから(初期値のまま)/ Reset Delay:3

    ⚠️ ①のPair Controller欄を先に埋めようとすると「まだChestPairにスクリプトが無い」状態になっています。②を先に済ませてから①に戻る、の順でもOKです。要は9つの欄がすべて埋まっていること。1つでも「なし(None)」が残っていると、その部分だけ無言で動きません。


    ゲーム開始「▶」で総合動作確認

    【Unity初心者🐣】

    深層(フェーズ)が「浅い / 中間」では『違和感のない』宝箱が正解。逆に「深い」だと『違和感のある』宝箱が正解。

    〜 深淵に進むと、世界線が反転する🪞〜ギミックの実装。

    開発デバッグ用のゲームマネージャでの切り替えも便利。#unity #ゲーム制作 #個人開発 pic.twitter.com/t0FTHHZwPZ

    — 辛島信芳@Androidアプリ開発🦖 (@quiet_sea) August 21, 2026
    1. 差分が付く:Playすると、どちらか片方の宝箱にだけ「向きのズレ/目の光(浅層では出ません)/余計な飾り/銘板の誤字」のどれかが付いている

    2. 調べて開ける:Vキーで一人称(FPS)にし、宝箱に照準を合わせると「[E] 開ける」が出る(第5弾のとおり、「調べる」はFPS専用です)

    3. 正解:差分のない方を開ける → フタが消えて中身が光り、「せいかい!」がふわっと表示 → 3秒後に両方リセットされ、次の抽選で別の差分が付く

    4. 不正解:差分のある方を開ける → 「はずれ……」表示 → 左上HUDの失敗が 1 に増える

    5. 連打防止:結果表示中に連打しても、二重に判定されない(isResolvingが効いている)

    6. 履歴:何度か繰り返し、同じ見分けポイントが3連続しないことを確認

    7. フェーズ絞り込み:浅層のままでは「目の光」が出ないことを確認

    8. XOR反転:Playのまま GameManager の Current Phase を Deep に変更 → HUDが「深層」になる → 次の抽選から、差分のある方を開けると正解になる(真理値表の下2行が現実になります)

    ⚠️ もし「[E]調べる」が出ない場合は、レイキャストが当たっていない可能性があります。「アバターの目線まで、宝箱の高さを持ってくる(浮かせるなど)」「石碑など他のレイキャストが当たるオブジェクトから距離を離す」などを試してください

    8つすべて通れば、今回の到達点クリアです🎉

    なお、プロンプトの「開ける」は今回、学習効率を優先してスクリプト直書きにしています(第6弾の手記と同じ方針)。多言語化したい方は、String Tableに prompt_open(開ける/Open)を追加し、TreasureChestのprompt欄をLocalizedString化してください。手順は第6弾の石碑・手記の改修とまったく同じです。


    おわりに

    今回、実装したもの

    • ScriptableObjectのカタログ(Unity 6の新テンプレートで作成。Play中書き換えの罠つき)

    • フェーズ別の重み+履歴Queueつき抽選機(累積和方式とフォールバックの定石)

    • 簡易GameManager(FogControllerと同じシングルトン型。第10弾で完成形に)

    • 差分の適用(回転・マテリアル差し替え・Instantiate・3D TMP文字差し替え)

    • XOR 1行の真贋判定と、深層での判定反転

    • CanvasGroupでフェードする結果UI(SetActiveに頼らない新しい型)

    画像
    仕掛けを増やす = アセットを増やすだけ

    そして何より大きいのは、「仕掛けを増やす=アセットを増やす」という体制が手に入ったことです。試しに、Dataフォルダで見分けポイントをもう1つ作ってみてください(例:Rotation型で rotationOffsetY = -15、名前は「逆向きのズレ」)。カタログに登録するだけで、コードを1文字も触らずに新しい差分が抽選に混ざります。この気持ちよさが、データ駆動の入口です。


    第8弾予告

    次回・第8弾は『迷いの回廊 — ループ・テレポート・導き』。第3弾で作った区画Prefabを連結して、いよいよ遺跡に「奥」を作ります。

    ここで作るのは、通路を延々と何十本もつなぐ長い迷路……ではありません。閉じた輪になった回廊です。分岐で判断を誤ると、霧が満ちて、気づけばまた同じ場所に立っている。進んでいるつもりが進めていない——という構造を作ります。

    💡 この「同じ場所を繰り返す」構造、ゲームとしては少数派で、有名な例は『P.T.』などホラー作品に偏っています。ところが実装そのものは、トリガーで判定してテレポートさせるだけと拍子抜けするほど簡単です。しかも通路を量産しなくて済むぶん、ブロックアウトの手間もゲーム容量も増えません。手軽な仕組みで「無限に奥がある」体験を作れる、コストパフォーマンスの高い手法というわけです。

    そして今回の宝箱ペアが、その分岐に置かれます。「遺跡を奥へ進む=フェーズが上がる」という結線がされるのも第8弾。冒頭でお話しした「エンジンにアクセルを繋ぐ」のがここです。今回インスペクターで設計した難易度カーブが、ようやくプレイヤーの体験として動き出します。

    あわせて、第4弾のFogControllerにも手を入れます(トグルではなく任意の濃さへフェードさせる FadeTo の追加)。「動作確認用」と書いておいたFogDemoToggleが役目を終える回でもあります。お楽しみに。

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

    あなたへのおすすめ