見出し画像

【Unity】一人称視点と「調べる」を実装する(自作Input Actions・TPS⇄FPS切替・Raycastインタラクション)


第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弾:『文字は世界の一部 — TMP日本語化・多言語対応・記帳』

  7. 📅 [執筆予定] 第7弾:『データで仕掛けを量産する — ScriptableObjectカタログと抽選機(心臓部) 』

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

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

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

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

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

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

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

前回(第4弾)では、遺跡の広間に暗所ライティングを敷き、Lキーで点け消しできるランタン、Fキーで切り替わるフォグをコルーチンで実装して、探索の「空気感」を作りました。ランタンを点けて薄暗い広間を歩けるようになったところで、私はふと欲が出ました。「せっかく遺跡を探索しているのだから、石碑みたいなものに近づいて『調べる』ことができたら、グッとゲームらしくなるのでは?」と。

第5弾の「2つの大きな目標」

そこで今回は、一人称(FPS)モードに切り替えて、石碑に視線を合わせると画面に『[E] Examine』というプロンプトが出て、Eキーで調べられる……ここまでを目標にします。調べた反応は、今回はまだ Debug.Log で確認するところまで(石碑の全文を画面に出す「Lore UI」は、日本語フォントの話とセットで次回・第6弾でやります)。

そして今回は、その前段としてもうひとつ大きな作業をします。Input Systemの入力定義を、プロジェクト既定の「InputSystem_Actions」への文字列追加方式から、自作の .inputactions アセット+型安全な生成クラスへ移行するという、いわば土台の作り直しです。地味ですが、ここをやっておくと今後がとても楽になります。

👩‍💻 こんな方に向けた内容です
・Input Systemを「とりあえず FindAction("Move")」で使ってきたが、アクションが増えて管理がつらくなってきた方。
・文字列参照のタイポでハマった(あるいはハマる予感がしている)方。
TPS(三人称)で作ってきたキャラを、一人称視点にも切り替えたい方。
・「視線の先のオブジェクトを調べる」インタラクションを作ってみたい方。
・Unity 6世代(6.x)で仕様が変わった点を、古い記事に惑わされず知りたい方。

🛠️ 動作検証環境
・macOS(Tahoe 26.5.1)
・Unity 6.5(6000.5.5f1)/URP 17.5.0
・Input System 1.20.0(Unity 6000.5にバンドルされる版)
・Cinemachine 3.1.7
・UniVRM 0.131.2(VRM 0.xのVRoid製アバター)
・Starter Assets - ThirdPerson URP 1.1.7(アニメーションのみ流用)
・ProBuilder 6.1.2
※ Unityエディタは日本語化しています(メニュー名は日本語で書き、必要に応じて英語も併記します)。

バージョンによってメニューの文言や既定値は変わります。本記事は上記環境での話です。「筆者の環境では」という前置きが多くなりますが、そこはご容赦ください。


本記事の構成

本記事は「入力の土台を作り直す → 視点を増やす → 『調べる』を作る → 落とし穴を知る」という流れの構成です。今回はコード量が多めですが、章ごとに必ず動く状態で区切ってあるので、1章ずつ動作を確かめながら進めてください。

※「第1章 はじめに」は略
  • 第2章「カスタム Input Actions アセット」
    今回の隠れた山場で、いちばん長い章です。文字列FindActionの限界→自作アセットへの移行(保存されない不具合の確認・複製・差し替え)→「Generate C# Class」で型安全化→既存3スクリプトの改修まで。見た目は何ひとつ変わらないのに、内部の土台が総入れ替えになります。次章で使う「⏳予約席」もここで仕込みます。

  • 第3章「TPS⇄FPS切替(CameraModeSwitcher)」
    Vキーで三人称⇄一人称を切り替えます。Cinemachineの止め方、頭部アンカー、自作マウスルック、一人称で自分の姿を消す小技(影だけ残す)まで。前章で仕込んだ予約席は、ここで有効化します。

  • 第4章「一人称の『調べる』(Raycastインタラクション)」
    今回のゴールです。石碑を置き、専用レイヤーで対象を絞り込み、画面中央からレイを撃って「[E] Examine」を表示。Eキーで反応するところまで作ります。プロンプトが英語な理由(日本語フォント問題)もここで説明します。

  • 第5章「Triggerとレイヤーの落とし穴」
    唯一、手を動かさない「読む章」です。ただし、OnTriggerEnterが発火しない三大原因と、Raycastがトリガーに当たらない設定の話は、この先の弾で作る「範囲に入ると反応する仕掛け」すべての前提になる知識です。飛ばさずどうぞ。

上記、5つの章の構成となってます(※「第1章 はじめに」は略)。

それでは始めましょう。


第2章 カスタム Input Actions アセット(Input Actionsを自作アセットへ移行する)

これまでのやり方の限界

第1弾・第4弾では、Unity 6が新規プロジェクトに自動生成する InputSystem_Actions というアセットをそのまま使い、コードからは

_moveAction = InputSystem.actions.FindAction("Move");

のように文字列でアクションを引く方式をとってきました。これは手軽で、最初のうちはとても良い方法です。

InputSystem.actionsの仕組み

実際、InputSystem.actions は「プロジェクト全体のアクション(Project-wide Actions)」に割り当てられたアセットを返すプロパティで、Unity公式マニュアルも「アセットへの参照を自分で用意しなくても、InputSystem.actions プロパティから直接アクションにアクセスできるのが利点」と説明しています。 

ところが、アクションが増えてくると、この方式には2つの弱点が見えてきます。

🧩 弱点1:文字列参照はタイポしても沈黙する
FindAction("Sprint") を FindAction("Sprnit") と書き間違えても、C#コンパイラは何も言ってくれません。コンパイルは通り、実行時に該当アクションが見つからず null が返り、後で NullReferenceException か「なぜか動かない」という形で表面化します。原因の特定に時間を溶かしがちです。

🧩 弱点2:管理しづらい
今回、Interact(調べる)とToggleView(視点切替)という新しいアクションが増えます。既存のMove / Look / Sprint / Jump / Lantern / Fogと合わせて8個。これらが文字列で各スクリプトに散らばっていると、名前を変えたいときに全ファイルを検索する羽目になります。

そこで、Unityが公式に用意している解決策が 「Generate C# Class(C#クラスの生成)」 です。.inputactions アセットから型安全なラッパークラスを生成し、controls.Player.Interact のようにドット区切りで補完が効く形でアクセスできるようにします。タイポはコンパイルエラーで弾かれます。

まず、いま使っている InputSystem_Actions をダブルクリックして、Actions Editorで中身を見てみます。Unity 6の新規プロジェクトの既定では、次の2つのアクションマップが入っています。

●「編集」→「プロジェクト設定」→「Input System Package 」

【Player マップ】Move / Look / Attack / Interact / Crouch / Jump / Previous / Next / Sprint(※ Lantern / Fog は本連載でこちらが追加)

【UI マップ】Navigate / Submit / Cancel / Point / Click / RightClick / MiddleClick / ScrollWheel / TrackedDevicePosition / TrackedDeviceOrientation

今回は自作アセットを新しく作るので、そこではInteractの挙動を素直な「押した瞬間に反応」にします(Holdは付けません)。「調べる」は長押しである必要がないですし、初心者的にも直感的だからです。

📝 なぜHoldだと一瞬押しでは反応しないのか?
Holdインタラクションは「コントロールを一定時間押し続けたら performed(アクション成立) が発火する」という仕組みです。


※ 次項の「複製」に進む前に

Unity 6.4〜6.5世代のInput System特有のバグがあるため、次項に進む前にProjectウィンドウ(Assetsフォルダ内)にあるInputSystem_Actions.inputactionsを直接開き、確実に保存する必要があります。

詳細は『⚠️ 【追記:2026年8月7日(重要なお知らせ)】』に記載しております。

これをしておかないと、次項でのGameControlsにて Lantern / Fog がきちんと複製されません。

ProjectウィンドウでAssets内のInputSystem_Actions をダブルクリックで開く

第4弾での「Lantern」「Fog」が追記されていることを確認し『Save Asset』ボタンを押す。


なぜ「複製」から始めるのが安全なのか

Unity 6の罠:「UIマップ」喪失によるボタン操作不能バグを防ぐ

自作アセットは、ゼロから作るのではなく 既定の InputSystem_Actions を複製(Cmd+D)してリネームする ところから始めるのが安全です。理由は、UIマップにあります。

⚠️ Unity 6以降、画面のボタン操作などのUIには、この「UI」マップが必須になりました。 もしゼロから空のアセットを作ってUIマップを入れ忘れたり、中身を消してしまったりすると、「ボタンが全くクリックできない!」という深刻なバグが起きます。公式でも注意喚起されているほど、消してはいけない重要なパーツとなります。

よって、手順は ……

  1. ProjectウィンドウでAssets内のInputSystem_Actions を選び、Cmd+D(複製)。

  2. 複製された方を GameControls にリネーム(名前は好きなものでOK。以後この名前で進めます)。

  3. GameControls をダブルクリックしてActions Editorを開く(※ Lantern / Fog もきちんと複製されていることを確認)。

  4. UIマップはそのまま触らずに温存。Playerマップだけを編集します。

📝 UIマップ、使ってないから消していい?
ダメです。今後メニューやボタンを置いた瞬間に効いてきます。触らず置いておきましょう。


アクションを確認・追加する

GameControls のPlayerマップに、この連載で使うアクションを揃えます。既存のものに、新規2つを足します。

  1. Interact(新規、または既定のInteractを流用)
    Eキー。Holdインタラクションは外して、押した瞬間に反応するように。

  2. ToggleView(新規)
    視点切替キー。Vキーとする。

  3. 編集後は『Save Asset』ボタンを押す。

※ キーの追加方法は第4弾でも使った 「By Character Mapped to Key」 を踏襲します。

⌨️ ToggleViewのキー選定
候補はVキーとTabキーでした。調べたところ、既定のPlayerマップではCrouch=Cキー、Sprint=Left Shiftが使われている一方、VキーもTabキーも既定では未使用でした(UIマップでも使われていません)。Tabは将来インベントリ等で使いたくなりがちなので、今回は競合の少ないVキーをToggleViewに割り当てます。


プロジェクト全体のアクションを差し替える

作った GameControls を、プロジェクト全体のアクションに割り当てます。

  1. 「編集」→「プロジェクト設定」→「Input System Package 」。

  2. Project-wide Actionsを先ほど作成した『GameControls』にする。

これで InputSystem.actions が返すアセットが GameControls に切り替わります。


「Generate C# Class」で型安全クラスを生成する

GameControls アセットを選び、インスペクターを見ます。

  1. 「Generate C# Class」 のチェックボックスをオンにする。

  2. すると、C# Class File(出力パス)、C# Class Name(クラス名)、C# Namespace(名前空間・任意)の入力欄が出ます。今回はデフォルト(クラス名 GameControls、名前空間なし)のまま進めます。

  3. 「適用する(Apply)」ボタンを押す。

これで GameControls.cs が生成されます。中身は自動生成コードなので、直接編集はしません(編集してもApplyで上書きされます)。


生成クラスの使い方(初心者がいちばん混乱するところ)

生成された GameControls.cs は触りません。PlayerLocomotion.cs のような、自分で書いたスクリプトの側から呼び出して使います。呼び出し方は以下のようになります。

// ※これは自分で書くスクリプトの例です(GameControls.cs ではありません)

using UnityEngine.InputSystem; // 名前空間

private GameControls _controls;

private void Awake()
{
    _controls = new GameControls(); // ① newでインスタンス化
}

private void OnEnable()
{
    _controls.Player.Enable(); // ② 使うマップを有効化(重要!)
}

private void OnDisable()
{
    _controls.Player.Disable(); // ③ 無効化
}

private void Update()
{
    // ④ 型安全にアクセス。補完が効く/タイポはコンパイルエラー
    Vector2 move = _controls.Player.Move.ReadValue<Vector2>();
    if (_controls.Player.Jump.WasPressedThisFrame()) { /* ジャンプ */ }
}
コードの中でどう呼び出すか?初心者が一番混乱するポイントと作法
  • ① new でインスタンスを作ります。生成クラスは内部に自分専用のアクションアセットのインスタンス(複製)を作る設計です。ここが混乱ポイントで、new GameControls() が持つアクションと、InputSystem.actions が返すアクションは別インスタンスです。両方とも独立に有効化でき、独立に動きます。今回は移行後は生成クラス側に統一するので、混ぜて使わないのが安全です。

  • ② Enable() を忘れると一切反応しません。 これは新Input Systemで最も多いミスです。使うマップ(今回はPlayer)を OnEnable で有効化します。

  • ③ OnDisable で Disable() します。

  • ④ 各アクションへは _controls.Player.アクション名 でアクセスします。値は ReadValue<T>()、押した瞬間は WasPressedThisFrame()。

📝 Dispose は必要?
生成クラスは IDisposable を実装しています。厳密には使い終わりに Dispose() するのが行儀の良い作法ですが、MonoBehaviourがシーンの寿命いっぱい生きる今回のようなケースでは、OnDisable で Disable() しておけば実用上は十分です。神経質にならなくて大丈夫です(気になる方は OnDestroy で _controls.Dispose() を呼んでおくと綺麗です)。

📝 複数スクリプトで共有すべき?
「1つ作って全員で共有」でも「各スクリプトが各自 new」でも、どちらも動きます。共有はメモリ的にわずかに得ですが、参照の受け渡しという手間が増えます。この規模の学習用プロジェクトなら、各スクリプトが自分の GameControls を new して、自分が使うマップだけEnableするのがいちばんシンプルで、事故も少ないです。本記事はこの方針でいきます。


既存スクリプトを改修する①:PlayerLocomotion.cs

第1弾で作った PlayerLocomotion.cs を改修します。今回の変更点は次の2つ+予約席1組です。

長いコードをコピペする前に、今回変更する「3つのポイント」を把握しよう
  1. 💡 文字列 FindAction → 生成クラス参照(型安全化。本章の主役です)

  2. 💡 InputLocked の導入(UIや演出中に操作を止めるためのフラグ。第6弾の布石です)

  3. ⏳ 予約席:次章のTPS⇄FPS切替で使う2箇所を、コメントアウトの形で仕込んでおきます

📝 コード中に「⏳【第3章で有効化】」というコメントアウトされた行が2箇所あります。これは次章で作る CameraModeSwitcher を使う部分の予約席です。いま有効化すると、まだ存在しないスクリプトを参照するため error CS0246: The type or namespace name 'CameraModeSwitcher' could not be found というコンパイルエラーになります。コメントのまま貼り付けてください。 外すのは第3章です。

まず変更・追記箇所を💡で示します。

// 💡 変更:Awakeでの文字列FindActionをやめ、生成クラスからアクションを取得
private GameControls _controls;

// 💡 追記:trueの間は移動・ジャンプ入力を止める(第6弾でUI・演出中に使う布石)
public bool InputLocked { get; set; } = false;

// 💡 追記:OnEnable / OnDisable でPlayerマップを有効化・無効化(生成クラスでは必須)
// 💡 追記:Update冒頭のロック判定と、ロック中も重力だけ効かせる ApplyGravityOnly()
// ⏳ 予約席:CameraModeSwitcher参照とFPS判定の2箇所(コメントアウトのまま)

以下が改修後の【 PlayerLocomotion.cs 】全体コードです。

// ==========================================================
// 📄 スクリプト:PlayerLocomotion.cs
// ==========================================================
using UnityEngine;
using UnityEngine.InputSystem; // InputAction型を使うため、引き続き必要です

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

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

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

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

    // ⏳【第3章で有効化】FPS判定のための参照(予約席 1/2)。
    // 次章で作る CameraModeSwitcher(TPS⇄FPS切替の管理役)をここに割り当てます。
    // いま有効化すると「CameraModeSwitcherが見つからない」というコンパイルエラー
    // (CS0246)になるため、次章でスクリプトを作ってからコメントを外します。
    // [SerializeField] private CameraModeSwitcher cameraModeSwitcher;

    // 💡 追記:trueの間、移動・ジャンプの入力を受け付けなくする「操作ロック」です。
    // 第6弾で「UIの表示中」や「演出中」にキャラが動かないようにするための布石で、
    // 外部から PlayerLocomotion.InputLocked = true; のように切り替えて使います。
    // ※プロパティなのでインスペクターには表示されません(コードから使う前提です)。
    public bool InputLocked { get; set; } = false;

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

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

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

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

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

        // 💡 変更:文字列で探すのをやめ、生成クラスから「型安全」に取得します。
        // 以前)_moveAction = InputSystem.actions.FindAction("Move");
        //   → "Mvoe" と書き間違えてもコンパイルは通り、実行時にnullで沈黙する
        // 今回)_controls.Player.Move
        //   → 書き間違えるとその場でコンパイルエラー。補完も効きます
        _controls     = new GameControls();
        _moveAction   = _controls.Player.Move;
        _sprintAction = _controls.Player.Sprint;
        _jumpAction   = _controls.Player.Jump;

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

    // 💡 追記:生成クラスのアクションは Enable() しないと一切反応しません。
    // 「新しいアセットに差し替えたのに動かない」の原因の第1位がこの付け忘れです。
    private void OnEnable()  { _controls.Player.Enable(); }
    private void OnDisable() { _controls.Player.Disable(); }

    private void Update()
    {
        // --------------------------------------------------------
        // 💡 0. 操作ロックの判定(追記)
        // --------------------------------------------------------
        // ロック中は入力を読まずに帰ります。ただし重力だけは効かせ続けます。
        // (空中でロックされたとき、宙に固まってしまわないように)
        if (InputLocked)
        {
            _animator.SetFloat(_speedHash, 0f); // 走行アニメを止める
            ApplyGravityOnly();                 // 重力のみ適用
            _animator.SetBool(_groundedHash, _controller.isGrounded);
            return;
        }

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

        // 💡 変更:生成クラスのアクションはnullになり得ないため、
        // 第1弾にあった「_sprintAction != null &&」のnullチェックが不要になりました。
        // これも型安全化のご利益のひとつです。
        bool isRunning = _sprintAction.IsPressed();

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

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

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

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

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

        // --------------------------------------------------------
        // 4. キャラクターを進行方向へ「滑らかに」振り向かせる
        // --------------------------------------------------------
        // ⏳【第3章で有効化】一人称(FPS)のときは、体の向きをマウスルックが担当
        // するため、この「進行方向への振り向き」をスキップする必要があります(予約席 2/2)。
        // 次章で下の1行を有効化し、直後のif文の条件に「 && !isFirstPerson 」を足します。
        // bool isFirstPerson = cameraModeSwitcher != null && cameraModeSwitcher.IsFirstPerson;

        if (inputMagnitude > 0.01f) // ⏳第3章で「 && !isFirstPerson 」を追加します
        {
            float targetAngle = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg;
            float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle,
                                                ref _turnSmoothVelocity, rotationSmooth);
            transform.rotation = Quaternion.Euler(0f, angle, 0f);
        }

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

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

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

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

    // 💡 追記:ロック中でも重力だけは処理するためのメソッド。
    // これが無いと、ロックした瞬間に空中で静止したままになったりします。
    // 中身は「5. 重力の計算」と同じロジックの縦方向だけ版です。
    private void ApplyGravityOnly()
    {
        if (_controller.isGrounded && _verticalVelocity < 0f)
        {
            _verticalVelocity = groundedGravity;
        }
        else
        {
            _verticalVelocity += gravity * Time.deltaTime;
        }
        _controller.Move(new Vector3(0f, _verticalVelocity, 0f) * Time.deltaTime);
    }

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

📝 移動の計算は、次章でFPSに切り替えたあともカメラ基準のままでいけます。一人称では体の向き=カメラの向き(マウスルックが回す)になるので、「WASDのWでカメラ前方へ進む」がそのまま自然に機能します。

■ 動作確認
ゲーム開始「▶」で確認します。移動・ダッシュ・ジャンプが従来どおり動けば成功です。見た目の変化はゼロですが、内部の入力参照が型安全に入れ替わりました。コンソールにCS0246が出ていないこと(⏳がコメントのままなら出ません)も確認してください。


既存スクリプトを改修する②:LanternController.cs

コード量は少ないですが、今後のための重要な仕掛けを追加します

第4弾の LanternController.cs も生成クラス方式へ。あわせて public bool IsOn => isOn; を追加します(今後の弾で使う布石です)。

💡差分

private GameControls _controls;          // 💡 追加:生成クラス
public bool IsOn => isOn;                // 💡 追加:今後の弾で使う布石です
// 💡 変更:OnEnableで _controls.Player.Lantern を取得し、Player.Enable()

全体コード【 LanternController.cs 】

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

public class LanternController : MonoBehaviour
{
    [SerializeField] private Light lanternLight;    // ランタンの光源
    [SerializeField] private Renderer glowRenderer; // 発光メッシュ

    private GameControls _controls;    // 💡 生成クラス
    private InputAction lanternAction; // Lantern(Lキー)
    private Material glowMaterial;
    private bool isOn = true;

    // 💡 追加:今後の弾で使う布石です
    public bool IsOn => isOn;

    private void Awake()
    {
        glowMaterial = glowRenderer.material; // インスタンス化されたマテリアルを取得
    }

    private void OnEnable()
    {
        _controls ??= new GameControls();         // まだ無ければ作る
        lanternAction = _controls.Player.Lantern; // 💡 型安全に取得
        _controls.Player.Enable();
    }

    private void OnDisable()
    {
        _controls.Player.Disable();
    }

    private void Update()
    {
        // Lキーを押した瞬間にオン/オフを反転
        if (lanternAction.WasPressedThisFrame())
        {
            isOn = !isOn;
            ApplyState(isOn);
        }
    }

    private void ApplyState(bool on)
    {
        lanternLight.enabled = on; // 光源のオン/オフ
        if (on) glowMaterial.EnableKeyword("_EMISSION");
        else    glowMaterial.DisableKeyword("_EMISSION");
        glowMaterial.SetColor("_EmissionColor",
            on ? Color.white : Color.black); // 発光色
    }
}

💡【C# 豆知識】??= ってなに?(Null合体割り当て演算子)
_controls ??= new GameControls(); は『もし _controls の中身が空っぽ(null)だったら、右側の new を実行して代入してね。もう入ってたら何もしないでね』という安全確認つきの代入を1行で書ける、とても便利な省略記法です。


既存スクリプトを改修する③:FogDemoToggle.cs

FogDemoToggle.cs 改修のポイント

FogController.cs(シングルトン+コルーチン FadeDensity)はそのままで、入力を受ける FogDemoToggle.cs だけ改修します。

全体コード【 FogDemoToggle.cs 】

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

public class FogDemoToggle : MonoBehaviour
{
    private GameControls _controls; // 💡 生成クラス
    private InputAction fogAction;   // Fog(Fキー)

    private void OnEnable()
    {
        _controls ??= new GameControls();
        fogAction = _controls.Player.Fog; // 💡 文字列FindActionから差し替え
        _controls.Player.Enable();
    }

    private void OnDisable()
    {
        _controls.Player.Disable();
    }

    private void Update()
    {
        // Fキーでフォグをトグル(本体はFogControllerのコルーチン)
        if (fogAction.WasPressedThisFrame())
            FogController.Instance?.ToggleFog();
    }
}

ゲーム開始「▶」で確認します。移動・ランタン(L)・フォグ(F)が今までどおり動けば、型安全クラスへの移行は成功です。見た目は何も変わりませんが、内部の土台が入れ替わりました。 ここが今回の隠れた山場でした。


第3章 TPS⇄FPS切替(CameraModeSwitcher)

この章でやること

Vキーで、三人称(TPS)と一人称(FPS)を瞬時に切り替えます。

inemachineBrain が有効な間は、スクリプトからの位置変更が「強制的に上書き」されてしまいます

第1弾のカメラは、Main Cameraに CinemachineBrain、別オブジェクトに CinemachineCamera(Orbital Follow+Hard Look At+Cinemachine Input Axis Controller)という構成でした。このCinemachineが効いている間は、Main CameraのTransformをスクリプトで動かしてもBrainが上書きしてしまいます。

Main Camera を誰が動かすか? 状況に合わせて主導権を切り替える

そこで以下のように、発想を変えます。切替はカット(瞬時)で十分です。

  • FPS中はCinemachineをオフにして、Main Cameraを頭部アンカーへ追従させ、自作のマウスルックで回す。

  • TPSに戻すときはCinemachineをオンに戻す。


Cinemachine 3系のコンポーネント名(表記統一)

Cinemachine 3系では2系から名前が変わっています。本記事では以下で統一します。

・CinemachineBrain(Main Cameraに付く司令塔)
・CinemachineCamera(旧Virtual Camera/vcam)
・CinemachineOrbitalFollow(Bodyの位置制御。Look Orbit X/YをLookに割当)
・CinemachineInputAxisController(入力をカメラ軸へ流すコンポーネント)


Cinemachineの止め方

Cinemachineを止める正攻法は「CinemachineCamera のGameObjectを非アクティブにする」ですが、今回はCinemachineBrain と CinemachineCamera の両方を enabled = falseにする方式でいきます。Brainを止めれば、Main CameraのTransformを誰も上書きしなくなり、こちらで自由に動かせます。

コンポーネントをON/OFFするだけで、賢くバトンタッチができる

TPS復帰時に両方を enabled = true に戻すと、CinemachineOrbitalFollow が元の軌道位置へカメラを戻してくれます(Brainが再びLive判定してMain Cameraを制御し直すため)。

📝 Brainだけ残してCinemachineCameraを止めた場合、Live対象のカメラが無くなって「No Cameras Rendering」の表示になり得ます。両方まとめてオン/オフする方が、初心者には事故が少ないです。


頭部アンカーを用意する

一人称の視点位置は、アバターの頭に置きます。VRoid製VRM(0.x)はHumanoidなので、Animator.GetBoneTransform(HumanBodyBones.Head) で頭ボーンのTransformが取れます(ボーン名はモデルで違うので、名前ではなくHumanBodyBonesで取るのが安全)。

今回のスクリプトは、この頭ボーンの子に「HeadAnchor」という空オブジェクトをコードで自動生成し、目の高さ・少し前へ微調整した位置に置きます。FPS時はMain Cameraをこのアンカーの位置に毎フレーム合わせ、向きはマウスルックで作ります。

■ CameraModeSwitcher.cs
Assets/Scripts内に『 CameraModeSwitcher.cs 』を新規作成します。

// ==========================================================
// 📄 スクリプト:CameraModeSwitcher.cs
// ==========================================================
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Rendering;   // ShadowCastingMode
using Unity.Cinemachine;       // Cinemachine 3系の名前空間

public class CameraModeSwitcher : MonoBehaviour
{
    [Header("参照")]
    [SerializeField] private Camera mainCamera;           // Main Camera
    [SerializeField] private CinemachineBrain brain;      // Main Cameraに付くBrain
    [SerializeField] private CinemachineCamera tpsCamera; // 三人称用vcam
    [SerializeField] private Animator avatarAnimator;     // アバターのAnimator
    [SerializeField] private Transform playerRoot;        // 体の回転対象(プレイヤー本体)

    [Header("マウスルック")]
    [SerializeField] private float sensitivity = 0.1f; // 感度
    [SerializeField] private float minPitch = -80f;    // 見下ろし限界
    [SerializeField] private float maxPitch = 80f;     // 見上げ限界

    public bool IsFirstPerson { get; private set; } = false;
    public bool CursorLocked => Cursor.lockState == CursorLockMode.Locked;

    private GameControls _controls;
    private InputAction _toggleAction;
    private InputAction _lookAction;

    private Transform _headAnchor;              // 頭部アンカー
    private float _pitch;                       // カメラの上下角
    private Renderer[] _renderers;              // アバターの全Renderer
    private ShadowCastingMode[] _originalModes; // 元のshadowCastingModeを退避

    private void Awake()
    {
        _controls = new GameControls();
        _toggleAction = _controls.Player.ToggleView;
        _lookAction   = _controls.Player.Look;

        // 頭ボーンの子にアンカーを生成
        Transform head = avatarAnimator.GetBoneTransform(HumanBodyBones.Head);
        var anchorGo = new GameObject("HeadAnchor");
        _headAnchor = anchorGo.transform;
        _headAnchor.SetParent(head);
        _headAnchor.localPosition = new Vector3(0f, 0.1f, 0.1f); // 目の高さへ微調整
        _headAnchor.localRotation = Quaternion.identity;

        // アバターの全Rendererと元のshadowCastingModeを控えておく
        _renderers = avatarAnimator.GetComponentsInChildren<Renderer>();
        _originalModes = new ShadowCastingMode[_renderers.Length];
        for (int i = 0; i < _renderers.Length; i++)
            _originalModes[i] = _renderers[i].shadowCastingMode;
    }

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

    private void Update()
    {
        // Vキーで切替
        if (_toggleAction.WasPressedThisFrame())
        {
            if (IsFirstPerson) EnterThirdPerson();
            else               EnterFirstPerson();
        }

        // FPS中だけマウスルック
        if (IsFirstPerson) HandleMouseLook();
    }

    private void LateUpdate()
    {
        // FPS中はアニメーションで動く頭に、カメラの「位置」を毎フレーム追従
        if (IsFirstPerson && _headAnchor != null)
            mainCamera.transform.position = _headAnchor.position;
    }

    private void EnterFirstPerson()
    {
        IsFirstPerson = true;
        brain.enabled = false;     // Cinemachineを止める
        tpsCamera.enabled = false;
        _pitch = 0f;
        Cursor.lockState = CursorLockMode.Locked; // カーソルをロック
        Cursor.visible = false;                   // 非表示
        SetRenderersShadowsOnly(true);            // 自分の姿を消す(影は残す)
    }

    private void EnterThirdPerson()
    {
        IsFirstPerson = false;
        SetRenderersShadowsOnly(false); // 姿を戻す
        brain.enabled = true;           // Cinemachineを戻す
        tpsCamera.enabled = true;
        Cursor.lockState = CursorLockMode.None; // カーソルを解放
        Cursor.visible = true;
    }

    private void HandleMouseLook()
    {
        // マウスデルタは「すでに1フレームの移動量」なのでTime.deltaTimeを掛けない
        Vector2 look = _lookAction.ReadValue<Vector2>();

        // 体(プレイヤー本体)をY軸回転(左右)
        playerRoot.Rotate(Vector3.up, look.x * sensitivity);

        // 上下(ピッチ)はClampで制限してカメラへ
        _pitch -= look.y * sensitivity;
        _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
        mainCamera.transform.rotation =
            Quaternion.Euler(_pitch, playerRoot.eulerAngles.y, 0f);
    }

    private void SetRenderersShadowsOnly(bool shadowsOnly)
    {
        for (int i = 0; i < _renderers.Length; i++)
        {
            _renderers[i].shadowCastingMode = shadowsOnly
                ? ShadowCastingMode.ShadowsOnly // 姿は消えるが影は残る
                : _originalModes[i];            // 元に戻す
        }
    }
}

■ アタッチと設定
CameraModeSwitcher.cs は、アバター(プレイヤーのルート)にアタッチします。

  1. ヒエラルキーでアバターを選択 →「コンポーネントを追加」→ CameraModeSwitcher を追加

  2. インスペクターに現れた5つの欄を割り当てます

    • Main Camera:ヒエラルキーの Main Camera

    • Brain:同じく Main Camera をドラッグ(付いているCinemachineBrainが自動で入ります)

    • Tps Camera:ヒエラルキーの CinemachineCamera オブジェクト

    • Avatar Animator:アバター自身(Animatorが入ります)

    • Player Root:アバター自身(Transform)


PlayerLocomotion の予約席を有効化する

CameraModeSwitcher ができたので、第2章で仕込んだ「⏳【第3章で有効化】」の2箇所を有効化します。

第2章で仕込んでおいたコードの封印を解き、FPS用の処理を繋げます

1. フィールド宣言のコメントを外す

[SerializeField] private CameraModeSwitcher cameraModeSwitcher; // ⏳→有効化

2. Update内④の1行のコメントを外し、直後のif文の条件を書き換える

bool isFirstPerson = cameraModeSwitcher != null && cameraModeSwitcher.IsFirstPerson;

if (inputMagnitude > 0.01f && !isFirstPerson) // 💡「&& !isFirstPerson」を追加

3. ヒエラルキーでアバターを選択し、インスペクターの PlayerLocomotion に現れた Camera Mode Switcher 欄へ、アバター自身をドラッグ&ドロップで割り当てる(同じオブジェクトに付けた CameraModeSwitcher が入ります)

⚠️ 2. の書き換えを忘れてもエラーは出ません。その代わり「一人称なのに、移動するとキャラ(=カメラ)が勝手に進行方向へ振り向いてしまう」という無言の不具合になります。FPSの視点がぐるっと持っていかれる感じがしたら、まずここを疑ってください。3. の割り当てを忘れた場合は、nullチェックのおかげでTPSと同じ挙動(振り向きあり)のままになります。


マウスルックとTime.deltaTimeの話(超重要)

注意点としては、マウスを回す処理に Time.deltaTime は掛けません。WASDキーでの移動などには必須の Time.deltaTime ですが、マウスの視点移動に掛けるのはNGです。

マウス入力に Time.deltaTime は掛けちゃダメ!


なぜなら、キーボード入力が「押しっぱなし」であるのに対し、マウス入力は「さっきの瞬間からどんだけ動かしたか(差分)」を直接受け取っているからです。 ここに Time.deltaTime を掛けてしまうと、「ゲームの動作が重くてカクついた瞬間、視点がギュン!とものすごい勢いで吹き飛ぶ」という最悪な操作バグの原因になります。上のコードでもそのまま(掛けずに)処理しています。

一方、WASDのような「押しっぱなしで一定値を返す入力」やジョイスティックは Time.deltaTime を掛けます(PlayerLocomotion の移動がそうですね)。「差分系(マウス移動・ホイール)は掛けない/定数系(キー・スティック)は掛ける」と覚えておくと安全です。


カーソルのロックと「Escキー」の罠

FPSモード中は、カーソルが邪魔にならないよう画面の真ん中に固定して見えないようにします(Cursor.lockState と Cursor.visible の設定)。

ここで初心者がよく驚く「罠」があります。Unityエディタでテストプレイをしている最中に【Escキー】を押すと、カーソルのロックが勝手に外れて、マウスカーソルが画面に現れて自由に動くようになってしまいます。

「急にマウスが動くようになった!」はバグではなく、エディタの親切設計です

「バグかな?」と思うかもしれませんが、これはUnityエディタの仕様です。ロックされたままだとUnityエディタ自体の操作ができなくなって困るため、強制解除される親切設計になっています。Unity公式のリファレンスにも「エディタではEscキーやアプリ切り替えでカーソルのフォーカスが外れる。ビルドしたゲーム(Standalone Player)ではカーソルを完全に制御できる」と明記されています。ビルド後の実行ファイルでは、Escで勝手に外れることはないので安心してください。

もしテスト中にEscを押してしまったら、もう一度「Vキー」を2回押して TPS → FPS と切り替え直してください。再びカーソルがロックされて直ります。

📝 なぜ切り替え直すと戻るのか? カーソルのロックは「一度設定したらずっと維持される」ものではなく、外れたら誰かがもう一度 Cursor.lockState = Locked を実行しないと戻らない性質のものだからです。今回のコードでは、この再設定を行うのが EnterFirstPerson()(Vキーで一人称に入る瞬間)だけなので、V→Vで一人称に入り直すのが復帰手順になります。

💡 もし「Gameビューをクリックしたら自動で再ロックされる」ようにしたければ、Update() の中で「FPS中なのにロックが外れていて、かつマウスクリックされたら再ロックする」という処理を足す方法もあります。今回はコードをシンプルに保つため、V→Vの手動復帰で運用します。


「一人称にすると自分の頭や体が映り込む」問題

一人称にすると、カメラが頭の中に入るので、自分のアバターの顔の裏側や体が視界を塞ぎます。

対策として今回は、アバターの全Renderer(SkinnedMeshRenderer含む)の shadowCastingMode を ShadowsOnly にする方式を使いました(上のコードの SetRenderersShadowsOnly)。

ただ消すだけじゃない!「存在感(影)」を残す ShadowsOnly の魔法

ShadowsOnly について、Unity公式のScripting API(Rendering.ShadowCastingMode.ShadowsOnly)は「essentially this makes an object that only casts shadows, but is otherwise invisible.(本質的に、影だけを落とし、それ以外は見えないオブジェクトになる)」と説明しています。姿は消えるのに地面の影は残るので、一人称でも自分の存在感が保てて良い感じです。切替時に元の値を配列へ退避し、TPS復帰で戻しています。

📝 VRM(MToonシェーダー)で効く?
shadowCastingMode はシェーダーではなくRenderer側の設定なので、MToonでも基本的に機能します。ただしMToonは影まわりの挙動が独特なので、「影の出方が変」と感じたら次の代替も検討してください。

🧩 代替案(コラム)
レイヤー+カメラのCulling Mask方式 アバターを専用レイヤー(例:PlayerBody)に置き、Main CameraのCulling Maskからそのレイヤーを外す方法もあります。こちらは影も含めて完全に消えます。さらにVRM 0.xには VRMFirstPerson コンポーネントがあり、Renderersの MeshAnnotation を ThirdPersonOnly にすると「一人称カメラでは頭を非表示」にできる、VRM本来の仕組みも用意されています(本来はVR用途向けですが考え方は同じです)。今回は一番手軽な ShadowsOnly 方式を採りました。

なお、FPS中も走り(Sprint)とジャンプは可のままです。PlayerLocomotion 側は特別な分岐なしでそのまま効きます。

■ 動作確認
ゲーム開始「▶」で確認します。Vキーで一人称/三人称が切り替わり、一人称ではマウスで見回せて、自分の姿が消える(影は残る)。さらに、一人称のままWASDで移動しても視点が進行方向へ勝手に振り向かないことも確認してください(先ほど有効化した予約席がここで効いています)。ここまで来たら第4章です。


第4章 一人称の「調べる」(Raycastインタラクション)

設計の全体像

「視線の先の石碑を調べる」を、次の3つで作ります。

レイキャストを発射する側、受け止める側、そして両者を繋ぐ「共通ルール」
  • IInteractable
    調べられるオブジェクトが実装するインターフェース。

  • Interactor
    カメラから前方へレイを撃ち、当たったものを調べる。プロンプトUIの出し入れもここで。

  • StoneMonument
    IInteractable を実装した石碑。今回 Interact() は Debug.Log だけ。

そして、レイが当てる相手を絞るために専用レイヤー「Interactable」を追加します。

💡 IInteractable がある理由
もし直接 StoneMonument を探す仕組みにすると、今後「ドア」や「宝箱」が増えるたびに Interactor.cs のコードを書き直さなければなりません。
しかし、「調べる時の約束事(GetPromptとInteract)」を `IInteractable` として定義しておけば、探す側(Interactor)は相手の正体を知らなくても、共通の命令を呼び出すだけでよくなります。


石碑オブジェクトを置く

まず、調べる対象になる「石碑」をシーンに作ります。見た目は後から差し替えられるので、いまはシンプルな箱で十分です。

  1. ヒエラルキーで右クリック ▸ 3Dオブジェクト ▸ キューブ(ProBuilderのキューブでもOK)

  2. 名前を Monument に変更

  3. インスペクターでスケールを調整(例:X:0.6 / Y:2 / Z:0.3 くらいにすると石碑らしくなります)

  4. 第3弾で作った広間(TreasureHall)の中、壁際あたりに配置

📝 キューブには最初からBox Colliderが付いています。これがレイの当たり判定になるので、そのままでOKです。


専用レイヤー「Interactable」を追加する

レイを撃つとき、壁や床まで拾ってしまうと「調べられないものにプロンプトが出る」ことになります。そこで「調べられるもの専用のレイヤー」を作り、レイの対象をそこだけに絞ります。

  1. 編集 ▸ プロジェクト設定 ▸ タグとレイヤー(Tags and Layers) を開く

  2. 「レイヤー」の左にある ▶ をクリックして展開する

  3. 一覧の中から User Layer 6 の欄をクリックし、Interactable と入力する

📝 展開すると、Builtin Layer(Unityが予約済み・グレーアウトして編集不可)と User Layer(自由に名前を付けられる枠)が並んでいるのが分かります。予約されているのは Default(0)/TransparentFX(1)/Ignore Raycast(2)/Water(4)/UI(5)の5つで、残りの User Layer 3、および 6〜31 が自由枠です。

📝 なぜ最初の空欄(Layer 3)ではなく6を使うのか?
Layer 3は予約枠のBuiltin Layer 2と4に挟まれた、ぽつんと1つだけの空きです。Unityが将来使う可能性を考えて避ける人が多いため、本記事も慣習に従って 6から使い始めることにしました。もちろん3を使っても動作に問題はありません。番号は7でも8でも構いませんが、本記事ではLayer 6を使った前提で進めます。


ヒエラルキーで先ほどの Monument を選択し、インスペクター右上の「Layer」ドロップダウンから Interactable を選ぶ。

💡 コードからは LayerMask.GetMask("Interactable") で、このレイヤーだけを対象にできます。

レイヤーで絞り込まないとどうなるか、図で見てみましょう。

LayerMask を使って、無関係な壁や床を「透明人間」のようにスルーする仕組み

マスクを指定しないと、壁も床も装飾も、レイが通る先にあるものはすべてヒット対象になります。

そのたびに「当たったものは調べられる物なのか?」を確認する必要がありますし、石碑の手前に小さな置物があるだけで、そちらが先にヒットして石碑を狙えなくなります。「調べられるものだけを候補にする」これがLayerMaskの役割です。


IInteractable.cs(新規)

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

// ==========================================================
// 📄 スクリプト:IInteractable.cs
// ==========================================================

public interface IInteractable
{
    string GetPrompt();  // プロンプトに出す文言(例:"Examine")
    void Interact();     // 調べたときの処理
}

シンプルに2メソッドだけ。これで「調べられるもの」を統一的に扱えます。


StoneMonument.cs(新規)

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

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

public class StoneMonument : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "Examine";

    public string GetPrompt() => prompt;

    public void Interact()
    {
        // 今回は反応確認のみ。全文表示のLore UIは第6弾で作ります。
        Debug.Log("石碑を調べた!(ここに碑文を表示する予定:第6弾)");
    }
}

■ アタッチ

  1. このスクリプトを、先ほど広間に置いた Monument にアタッチします(ヒエラルキーで Monument を選択 →「コンポーネントを追加」→ StoneMonument)。

  2. インスペクターに Prompt 欄が現れるので、Examine のままにしておきます。ここを書き換えれば、オブジェクトごとに違う文言(Open、Read など)を出せます。


Interactor.cs(新規)

画面中央からレイを撃ちます。中央は Camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)) で得られます(ビューポート座標は左下0,0・右上1,1なので中央は0.5, 0.5)。

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

// ==========================================================
// 📄 スクリプト:Interactor.cs
// ==========================================================
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;               // 手が届く距離

    private GameControls _controls;
    private InputAction _interactAction;
    private int _interactableMask;
    private IInteractable _current; // 今狙っている対象

    private void Awake()
    {
        _controls = new GameControls();
        _interactAction = _controls.Player.Interact;
        _interactableMask = LayerMask.GetMask("Interactable");
    }

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

    private void Update()
    {
        // TPS中は動かさない(カメラとキャラの間に壁が挟まる等の齟齬が出るため)
        if (cameraModeSwitcher == null || !cameraModeSwitcher.IsFirstPerson)
        {
            _current = null;
            SetPrompt(null);
            return;
        }

        // 画面中央からレイを撃つ
        Ray ray = mainCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));

        if (Physics.Raycast(ray, out RaycastHit hit, maxDistance,
                _interactableMask, QueryTriggerInteraction.Collide))
        {
            // コライダー自身、なければ親をたどってIInteractableを探す
            IInteractable it = hit.collider.GetComponentInParent<IInteractable>();
            if (it != null)
            {
                _current = it;
                SetPrompt($"[E] {it.GetPrompt()}"); // 例:[E] Examine
                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);
    }
}

■ アタッチ

Prompt Text 欄は、次項でUIを作ってから割り当てます

Interactor はアバター(プレイヤー)にアタッチし、Main Camera 欄にMain Cameraを、Camera Mode Switcher 欄にアバター自身を割り当てます。Prompt Text 欄は、次項でUIを作ってから割り当てます。

📝 TryGetComponent と GetComponentInParent の使い分け
当たったコライダー自身に IInteractable が付いていれば hit.collider.TryGetComponent(out IInteractable it) で即取得できます。一方、石碑のコライダーが子オブジェクトにあり、スクリプトは親に付いている、という構成なら GetComponentInParent で親をたどる必要があります。上のコードは後者に統一していますが、パフォーマンスを詰めたいときは「まず TryGetComponent、ダメなら GetComponentInParent」の二段構えにすると無駄が減ります。

📝 QueryTriggerInteraction.Collide を明示しているのは、石碑のコライダーがトリガーでも確実に当てたいからです(次章で詳説)。通常のソリッドなコライダーなら UseGlobal のままでも当たります。

⚠️ いまの実装の弱点
壁越しに調べられてしまう LayerMask は「Interactableレイヤーのものだけを候補にする」フィルターなので、壁や床はレイの判定対象から完全に外れます。裏を返すと、石碑と自分の間に壁があっても、距離が3m以内ならヒットしてしまうということです。今回は広間に石碑を1つ置いただけなので気になりませんが、通路が入り組んでくると「壁の向こうの石碑が調べられる」という妙なことが起きます。 対策は「Interactableに当たったあと、もう一度、壁を含むレイヤーでレイを撃ち、間に遮るものが無いかを確かめる」という二段構え(遮蔽判定)です。まったく同じ考え方を第9弾の「番人がこちらを見つけられるか」の判定でも使うので、そこで改めて扱います。


プロンプトUI(uGUI + TextMeshPro)

ヒエラルキーで右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro を選ぶ

初回はTMP Essentialsのインポートを求められるので、「Import TMP Essentials」を押します。

📝 下のボタン(TMP Examples & Extras)とは
TextMeshProの機能を学ぶためのサンプルシーン・デモ用フォント・追加マテリアルなどの詰め合わせです。動作には一切不要で、あくまで学習用のオマケです。「容量とファイル数が増える」「ビルドやプロジェクトが散らかる」の2点より、今回は押さないものとします。


生成された 子の「Text (TMP)」 を選択し、名前を PromptText に変更します(親の Canvas はそのままでOK)。インスペクターの Text Input 欄に、仮の文言として [E] Examine と入力しておきます。

📝「ənimɒxƎ [Ǝ]」と鏡文字になっていて驚いたかもしれませんが、これはシーンビューを裏側から見ているだけです。UIは板ポリゴンなので、裏に回ると鏡文字に見えます。確認はゲームビューで行ってください。そちらでは正しく [E] Examine と表示されているはずです。

なお、この際にEventSystem も一緒に生成されます(画像で言うヒエラルキーの1番下)。

EventSystemはUIの「入力受付係」です。ボタンのクリック、ホバー、キーボードでのUIナビゲーションといった操作を検知して、対象のUI要素へ届ける役目を持ちます。シーンに1つあれば十分で、これが無いとUIが一切反応しません。

📝 今回作ったのは表示するだけのテキストなので、実は無くても動きます。ただ、第6弾でノートUIや名前入力(TMP_InputField)を作る際には必須になるので、このまま置いておいてください


アンカーを画面中央下あたりに設定(インスペクター左上のアンカープリセットから「bottom center」を選び、Pos Yを150前後に)

このオブジェクトを Interactor の Prompt Text 欄にドラッグ。

ゲーム開始「▶」で確認します。Vキーで一人称にし、石碑に照準(画面中央)を合わせると [E] Examine が出て、Eキーで Debug.Log がコンソールに出れば成功です。

📝 なぜInteractorはFPS時のみ動かすのか レイをカメラから撃つ設計なので、三人称だとカメラがキャラの背後にあるぶん、カメラとキャラの間に壁が挟まったときに齟齬が出ます。そこで本連載では「調べる」を一人称専用と割り切りました。移動やアクションは三人称で、じっくり観察したいときは一人称に切り替える。視点の切替そのものに遊びの意味が生まれます。

⚠️ なぜ英語表記なのか?
プロンプトを日本語(例:「調べる」)にすると、□□(豆腐)になって表示されません。理由は、TextMeshProの既定フォント 「LiberationSans SDF」が日本語のグリフ(字形)を含んでいないからです。TMPはあらかじめ焼き込んだ atlas(字形の画像)しか描けず、既定フォントには英数字と一部記号しか入っていません。日本語を出すには、日本語フォントから専用のフォントアセットを作る必要があります。この作業は次回・第6弾でじっくりやるので、今回は英語で仮置きします。「[E] Examine」なら LiberationSans で問題なく出ます。


💡コラム: 三人称でも調べられるようにするには?

実は市販のゲームは、三人称のまま調べられる作品がほとんどです(ゼルダやソウル系など)。コツはカメラを起点にしないこと。今回のコード(Interactor.cs)なら、レイを作っている1行を次のように書き換えるだけで、三人称でも壁の問題なく動きます。

// 変更前:カメラの中央から撃つ(FPS向き)
Ray ray = mainCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));

// 変更後:キャラの目の高さから、キャラの正面へ撃つ(TPSでもOK)
Vector3 origin = transform.position + Vector3.up * 1.4f; // 胸〜目のあたり
Ray ray = new Ray(origin, transform.forward);

起点がキャラ本体になるので、カメラとの間に壁があっても関係なくなります(一人称のときも体の向き=カメラの向きなので、そのまま機能します)。あわせて Update() 冒頭のFPS判定(if (cameraModeSwitcher == null || !...IsFirstPerson) のブロック)を外せば、三人称でもプロンプトが出るようになります。


インスペクターで切り替えられるようにする(差し替え版【Interactor.cs】全体コード)

とはいえ、試すたびにコードを書き換えるのは面倒です。bool のフィールドを1つ足しておけば、インスペクターのチェックボックスひとつで切り替えられるようになります。以下は Interactor.cs をまるごと差し替える版です。本編のコードで動作を確認したあと、興味があれば試してみてください。

// ==========================================================
// 📄 スクリプト:Interactor.cs
// ==========================================================
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 IInteractable _current; // 今狙っている対象

    private void Awake()
    {
        _controls = new GameControls();
        _interactAction = _controls.Player.Interact;
        _interactableMask = LayerMask.GetMask("Interactable");
    }

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

    private void Update()
    {
        // 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))
        {
            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);
    }
}

インスペクターに現れる Allow Third Person をオンにすれば三人称でも調べられ、オフに戻せば一人称専用に戻ります。スクリプトを書き換える必要がないので、実際に両方の操作感を比べてみるのに便利です。

📝 三人称で試すときは、キャラの向いている方向が判定の基準になる点に注意してください。カメラをどれだけ動かしても、キャラの正面に石碑が来ていないと反応しません。「カメラの向き」と「体の向き」が別物であることを実感できる、良い練習でもあります。

もっと狙いを寛容にしたい場合は、レイをやめて Physics.OverlapSphere で周囲の候補を集め、Vector3.Angle で「正面120度以内の、いちばん正面に近いもの」を選ぶ方式もあります。ゼルダ系の挙動はこちらに近いです。

本連載でFPS専用にしたのは、「画面中央からレイを撃つ」というFPSの王道パターンをそのまま学びたかったからです。近接方式のほうが実装は簡単ですが、それだとRaycastの練習にならないため。

ゲーム開始「▶」で確認します。Vキーで一人称にし、石碑に照準(画面中央)を合わせると [E] Examine が出て、Eキーで Debug.Log がコンソールに出れば成功です。


■ 視点切替ゲームの「調べる」3つの実装パターン

市販ゲーム(スカイリムやマイクラ等)は、視点によって「できること」を制限しない設計が基本です。

本連載で「調べる」だけをFPS(一人称)専用にしたのは、Raycastの王道パターンを素直に学ぶためと、視点切替に遊び上の意味を持たせるためです。

🛠️ よって、本連載では「インスペクターに現れる Allow Third Person をオフ」を前提で進めていきます。

このゲームの主役はあくまでTPS(三人称)で、走る・跳ぶ・逃げるといったアクションは今後もすべて三人称のまま進めます(第9弾の「番人から隠れる」なども三人称です)。一人称は「じっくり観察するためのモード」という位置づけです。


■ このパターンは市販ゲームにもある?
「三人称で動き、観察したいときだけ一人称」という組み合わせは、実はよくある形です。ゼルダのブレワイ/ティアキンは基本が三人称で、スティック押し込みで一人称の見回しモードに入り、遠くの地形や目印を探せます(このモードでは移動できません)。モンスターハンターの見渡しモードも同じ発想です。

「TPSで動き、FPSで観察する」のは王道アクションの延長線上の発想です

ただ、これらの作品では一人称は「見る」だけで、調べたり拾ったりは三人称のまま行います。本連載のように「一人称で調べる」まで担わせるのは、少し欲張った設計です。とはいえ、動くときと見るときで視点を分けるという発想自体は、王道の延長線上にあります。


第5章 Triggerとレイヤーの落とし穴

趣旨

最後に、今回のRaycastやTriggerで初心者が必ずと言っていいほどハマる3点を、正確に整理します。


落とし穴①:OnTriggerEnterが発火する条件

OnTriggerEnter が呼ばれるには、原則として接触する2つのコライダーの少なくとも一方にRigidbodyが必要です。かつ、どちらかの Is Trigger がオンであること。Rigidbodyが両方に無いと、Unityの物理エンジンはその接触を「監視対象」とみなさず、イベントを一切発火しません。「トリガーに入っているはずなのにOnTriggerEnterが呼ばれない」の最頻出原因がこれです。

🧩 CharacterControllerの例外的挙動
ここが要注意です。この連載のプレイヤーは CharacterController で Move() して動かしています。CharacterController は特殊で、Move()/SimpleMove() で動かしたとき、Rigidbodyが無くてもトリガーコライダーに入ると OnTriggerEnter が発火します(CharacterController自身が特別な当たり判定を持つため)。したがって「プレイヤーが範囲に入ったら反応するトリガー」は、プレイヤー側にRigidbodyを付けなくても作れることが多いです。ただし挙動はバージョンや構成で差が出やすいので、筆者の環境では動きましたが、必ずご自身の環境で確認してください。 確実にしたいなら、片方にKinematicなRigidbodyを付けるのが定石です。


落とし穴②:Is Triggerの入れ忘れ/レイヤー衝突マトリクス

  • Is Triggerの入れ忘れ
    トリガーとして使いたいコライダーの Is Trigger にチェックが入っていないと、物理的に押し合う普通のコライダーとして振る舞い、OnCollisionEnter 側になります。

  • レイヤー衝突マトリクスの見落とし
    編集 ▸ プロジェクト設定 ▸ 物理 ▸ 設定(Physics Settings)を開き、Shared タブにある Layer Collision Matrix の ▶ を展開すると、レイヤー同士が衝突するかを決める三角形の表が現れます。ここで対象レイヤー同士のチェックが外れていると、トリガーもレイキャストも当たりません。専用レイヤー Interactable を作ったときは、必要な組み合わせにチェックが入っているか確認しましょう(新規レイヤーは既定で全部オンなので、通常はそのままでOKです)。

※ 行と列が交わるマスのチェックが「そのレイヤー同士が衝突するか」を表す。追加した Interactable も既定で全部オンになっている。


📝 Unity 6.5では、物理の設定が「物理」(使用する物理エンジンの選択)と「物理 ▸ 設定」(重力やマトリクスなどの中身)に分かれています。古い記事では1つの画面にまとまっていたので、「一番下までスクロールしても無い」と迷ったら、この階層を思い出してください。なお「2D 物理」は2Dゲーム用の別設定なので、本連載では使いません。


落とし穴③:RaycastとトリガーとQueryTriggerInteraction

Physics.Raycast がトリガーコライダーに当たるかどうかは、既定では 編集 ▸ プロジェクト設定 ▸ 物理 ▸ 設定 の GameObject タブにある 「トリガーヒットをクエリ」(英語版では Queries Hit Triggers)の設定に従います(引数の既定が QueryTriggerInteraction.UseGlobal =「グローバル設定に従う」という意味だからです)。この設定がオフだと、いくら石碑にレイを向けても、そのコライダーがトリガーだとヒットしません。

■ 対策は2つ

  1. グローバル設定「トリガーヒットをクエリ」をオンにする(プロジェクト全体に影響)。

  2. 個別のRaycastで明示する:QueryTriggerInteraction.Collide(トリガーも拾う)/QueryTriggerInteraction.Ignore(トリガーを無視)。第4章のInteractorでは Collide を明示して、石碑のコライダーがトリガーでも確実に当たるようにしています。

📝 前の項のLayer Collision Matrixは Shared タブ、こちらは GameObject タブと、同じ「物理 ▸ 設定」の中でもタブが分かれています。見つからないときはタブを切り替えてみてください。

📝 逆に「弾がトリガーをすり抜けてほしい」ようなケースでは Ignore を明示します。グローバルに頼らず、レイごとに意図を書くと事故が減ります。


おわりに

今回、実装したものです。

  • 文字列 FindAction から型安全な生成クラスへの移行(UIマップを壊さない複製手順つき)。

  • 既定InteractのHoldインタラクションという落とし穴(既定のhold時間は0.4秒)。

  • Cinemachine 3をオフにして作るTPS⇄FPS切替。

  • 画面中央レイによる「調べる」インタラクション。

  • そしてTrigger/Raycastの落とし穴。

石碑に [E] Examine が出てEで反応する、探索ゲームの背骨がまた一本通りました。

また、「プロジェクト設定から編集したアクションがファイルに保存されない」という現在進行形の不具合も踏み抜きました(第2章)。「エディタでは見えるのに複製すると消えている」同じ現象で首をかしげている方の助けになれば幸いです。

次回「第6弾」では、いよいよTMPの日本語化に取り組み、今回英語で仮置きしたプロンプトや、石碑の碑文(Lore UI)を日本語でちゃんと表示できるようにします。今回仕込んだ InputLocked や LanternController.IsOn も、その先で効いてきます。

最後まで読んでいただき、ありがとうございました。同じところでハマった方の時間短縮になれば嬉しいです🐣

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

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