Unityを触り始めると、つい Update() の中に「移動」「ジャンプ」「アニメーション」「入力」「エフェクト」など、全部を詰め込んでしまいがちですよね。最初は動くので満足できますが、あとから「ジェットパックを追加したい」「燃料管理を入れたい」といった要件が出てくると、巨大な PlayerController スクリプトに手を入れることになり、バグの温床になります。

そこで、機能ごとにコンポーネントを分割していくアプローチが重要になります。今回は「ボタンを押している間だけ上昇力を加え続け、燃料を消費する」挙動を、Jetpack という単独のコンポーネントに切り出してみましょう。プレイヤーにも敵にも、動くギミックにも、Jetpackコンポーネントをアタッチするだけで同じロジックを再利用できるようにします。

【Unity】空を制するなら燃料管理から!「Jetpack」コンポーネント

ここでは Unity6(C#)で動く、Rigidbody を使った物理ベースのジェットパックを実装します。

  • ボタンを押している間だけ上昇力を加える
  • 燃料がある間だけ噴射できる
  • 燃料はゆっくり自然回復する(地上にいるときだけ、などの条件も付けられる)
  • Input System(新Input)対応

フルコード:Jetpack.cs


using UnityEngine;
using UnityEngine.InputSystem; // 新Input Systemを使う場合

/// <summary>
/// ジェットパック機能を提供するコンポーネント。
/// ・ボタン押下中は上方向に力を加え続ける
/// ・燃料を消費し、0になると噴射できない
/// ・燃料は時間経過で回復(任意でON/OFF)
///
/// 想定使用例:
/// ・プレイヤーキャラクターの空中移動
/// ・ジェットで浮遊する敵
/// ・ジェットで上下する移動ギミック
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class Jetpack : MonoBehaviour
{
    // ====== 物理関連 ======
    [Header("Jetpack 設定")]
    [Tooltip("ジェット噴射による上方向の力 (Newton)。数値が大きいほど強く上昇します。")]
    [SerializeField] private float thrustForce = 15f;

    [Tooltip("力の適用モード。通常は Force でOK。InstantVelocityChange だと瞬間的に速度を変えます。")]
    [SerializeField] private ForceMode forceMode = ForceMode.Force;

    // ====== 燃料関連 ======
    [Header("燃料設定")]
    [Tooltip("燃料の最大値。")]
    [SerializeField] private float maxFuel = 5f;

    [Tooltip("1秒あたり消費する燃料量。ボタン押下中のみ減少。")]
    [SerializeField] private float fuelConsumptionPerSecond = 1f;

    [Tooltip("1秒あたり回復する燃料量。")]
    [SerializeField] private float fuelRechargePerSecond = 0.5f;

    [Tooltip("燃料を自動回復させるかどうか。")]
    [SerializeField] private bool autoRecharge = true;

    [Tooltip("燃料回復を地上にいるときだけに制限するか。")]
    [SerializeField] private bool rechargeOnlyOnGround = true;

    // ====== 入力関連 ======
    [Header("入力設定")]
    [Tooltip("Input System のアクション(例えば 'Jetpack' ボタン)。未設定の場合は OnJetpackInput を外部から呼び出してください。")]
    [SerializeField] private InputActionReference jetpackAction;

    [Tooltip("入力がなくても常にジェットを噴射するか(デバッグ・ギミック用)。")]
    [SerializeField] private bool alwaysThrust = false;

    // ====== 地面判定(任意) ======
    [Header("地面判定(オプション)")]
    [Tooltip("地面レイヤー。rechargeOnlyOnGround が true のときに使用します。")]
    [SerializeField] private LayerMask groundLayer = ~0; // デフォルトですべて

    [Tooltip("地面判定用のオフセット位置(キャラの足元など)。")]
    [SerializeField] private Transform groundCheckPoint;

    [Tooltip("地面判定の半径。小さめに設定しましょう。")]
    [SerializeField] private float groundCheckRadius = 0.2f;

    // ====== 内部状態 ======
    private Rigidbody rb;
    private float currentFuel;
    private bool isThrusting; // 現在入力により噴射しようとしているか

    // プロパティで外部から燃料状況を参照できるようにしておくと便利
    public float CurrentFuel => currentFuel;
    public float MaxFuel => maxFuel;
    public bool IsThrusting => isThrusting && currentFuel > 0f;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();

        // 開始時は燃料満タンにしておく
        currentFuel = maxFuel;
    }

    private void OnEnable()
    {
        // InputActionReference が設定されていれば、自動的に購読する
        if (jetpackAction != null && jetpackAction.action != null)
        {
            jetpackAction.action.performed += OnJetpackPerformed;
            jetpackAction.action.canceled += OnJetpackCanceled;

            // 有効化されていなければ有効化
            if (!jetpackAction.action.enabled)
            {
                jetpackAction.action.Enable();
            }
        }
    }

    private void OnDisable()
    {
        if (jetpackAction != null && jetpackAction.action != null)
        {
            jetpackAction.action.performed -= OnJetpackPerformed;
            jetpackAction.action.canceled -= OnJetpackCanceled;
        }
    }

    private void FixedUpdate()
    {
        // 物理挙動は FixedUpdate で処理する
        HandleThrust(Time.fixedDeltaTime);
        HandleFuelRecharge(Time.fixedDeltaTime);
    }

    /// <summary>
    /// ジェット噴射の処理。FixedUpdate から呼ばれます。
    /// </summary>
    private void HandleThrust(float deltaTime)
    {
        // 噴射条件:
        // 1. alwaysThrust が true なら常に
        // 2. それ以外は isThrusting && 燃料が残っている
        bool shouldThrust = alwaysThrust || (isThrusting && currentFuel > 0f);

        if (!shouldThrust)
        {
            return;
        }

        // 上方向に力を加える(ワールド座標系の Vector3.up を使用)
        rb.AddForce(Vector3.up * thrustForce, forceMode);

        // 燃料を消費
        ConsumeFuel(deltaTime);
    }

    /// <summary>
    /// 燃料消費処理。
    /// </summary>
    private void ConsumeFuel(float deltaTime)
    {
        if (fuelConsumptionPerSecond <= 0f)
        {
            return; // 消費量0なら何もしない
        }

        currentFuel -= fuelConsumptionPerSecond * deltaTime;
        if (currentFuel < 0f)
        {
            currentFuel = 0f;
        }
    }

    /// <summary>
    /// 燃料回復処理。autoRecharge が true のときのみ。
    /// </summary>
    private void HandleFuelRecharge(float deltaTime)
    {
        if (!autoRecharge)
        {
            return;
        }

        if (currentFuel >= maxFuel)
        {
            return; // すでに満タン
        }

        // 地上限定回復の場合は接地しているか確認
        if (rechargeOnlyOnGround && !IsGrounded())
        {
            return;
        }

        if (fuelRechargePerSecond <= 0f)
        {
            return;
        }

        currentFuel += fuelRechargePerSecond * deltaTime;
        if (currentFuel > maxFuel)
        {
            currentFuel = maxFuel;
        }
    }

    /// <summary>
    /// 簡易的な地面判定。SphereCast ではなく OverlapSphere で足元をチェック。
    /// </summary>
    private bool IsGrounded()
    {
        if (groundCheckPoint == null)
        {
            // groundCheckPoint が未設定なら、常に「地上ではない」とみなす
            return false;
        }

        // 指定位置に小さな球を出して地面レイヤーに触れているか確認
        Collider[] hits = Physics.OverlapSphere(
            groundCheckPoint.position,
            groundCheckRadius,
            groundLayer,
            QueryTriggerInteraction.Ignore
        );

        return hits.Length > 0;
    }

    // ====== Input System からのコールバック ======

    /// <summary>
    /// InputAction が performed されたとき(ボタンが押されたとき)に呼ばれる。
    /// </summary>
    private void OnJetpackPerformed(InputAction.CallbackContext context)
    {
        // ボタンが押されている間だけ isThrusting = true にしておく
        isThrusting = true;
    }

    /// <summary>
    /// InputAction が canceled されたとき(ボタンが離されたとき)に呼ばれる。
    /// </summary>
    private void OnJetpackCanceled(InputAction.CallbackContext context)
    {
        isThrusting = false;
    }

    // ====== 外部から入力状態を渡したいとき用のAPI ======

    /// <summary>
    /// 新Input Systemを使わず、外部スクリプトからジェット入力を制御したい場合に呼び出す。
    /// 例: OnJetpackInput(Input.GetKey(KeyCode.Space));
    /// </summary>
    public void OnJetpackInput(bool isPressed)
    {
        isThrusting = isPressed;
    }

    // ====== デバッグ用の可視化 ======
#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (groundCheckPoint == null)
        {
            return;
        }

        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(groundCheckPoint.position, groundCheckRadius);
    }
#endif
}

使い方の手順

ここからは、実際に Unity6 のシーンでこの Jetpack コンポーネントを使う手順を説明します。

手順①:Rigidbody付きのオブジェクトを用意する

  • 例1:プレイヤーキャラクター
    • ヒエラルキーで Player オブジェクトを作成
    • Capsule などの3Dオブジェクトを子にして見た目を作る
    • Rigidbody コンポーネントを追加する(質量や重力は適宜調整)
  • 例2:浮遊する敵
    • EnemyDrone などのオブジェクトを作成
    • 同じく Rigidbody を追加
  • 例3:上下に動く床(ギミック)
    • MovingPlatform オブジェクトを作成
    • 物理挙動をさせたい場合は Rigidbody(Is Kinematic の設定は要件に応じて)

手順②:Jetpack コンポーネントをアタッチする

  1. 上で作成した Player(または EnemyDrone / MovingPlatform)を選択
  2. Add Component から Jetpack を検索して追加
  3. thrustForce を 10〜20 あたりで調整して、ちょうどよい上昇力にする
  4. maxFuel, fuelConsumptionPerSecond, fuelRechargePerSecond を好みに合わせて設定

手順③:地面判定(任意)を設定する

燃料回復を「地上にいるときだけ」にしたい場合:

  1. rechargeOnlyOnGround にチェックを入れる
  2. Player(または対象オブジェクト)の足元付近に空の子オブジェクトを作成し、GroundCheck などの名前を付ける
  3. JetpackgroundCheckPoint に、その子オブジェクトをドラッグ&ドロップ
  4. groundCheckRadius を 0.1〜0.3 程度に調整
  5. groundLayer に「地面」として扱うレイヤー(Default など)を設定

これで、キャラクターが地面に接しているときだけ燃料が回復するようになります。空中で連打しても、燃料が切れたら一度着地する必要が出てくるので、ゲームデザイン的にも良い制限になりますね。

手順④:入力を設定する(プレイヤー例)

新Input Systemを使う場合
  1. Input Actions アセット(例: PlayerInputActions.inputactions)を作成し、Jetpack という Action を追加
  2. Action Type は Button、Binding に Space キーやゲームパッドのボタンを割り当てる
  3. アセットを保存し、InputActionReference を作成、または PlayerInput コンポーネントから参照を取得
  4. Jetpack コンポーネントの jetpackAction に、その ActionReference をドラッグ&ドロップ

これで、指定したボタンを押している間だけジェットパックが噴射されます。

古い Input.GetKey を使いたい場合(簡易)

既存のプロジェクトで新Input Systemを使っていない場合は、外部スクリプトから OnJetpackInput を呼び出す方法がおすすめです。


using UnityEngine;

/// <summary>
/// 既存のプレイヤー制御スクリプトから Jetpack コンポーネントを操作する例。
/// </summary>
[RequireComponent(typeof(Jetpack))]
public class PlayerJetpackInput : MonoBehaviour
{
    [SerializeField] private KeyCode jetpackKey = KeyCode.Space;

    private Jetpack jetpack;

    private void Awake()
    {
        jetpack = GetComponent<Jetpack>();
    }

    private void Update()
    {
        // キーが押されているかどうかを Jetpack に渡すだけ
        bool isPressed = Input.GetKey(jetpackKey);
        jetpack.OnJetpackInput(isPressed);
    }
}

こうして入力処理も別コンポーネントに分けておくと、Jetpack 自体は「入力元」を知らなくて済むので、敵AIやギミックからも同じインターフェースで再利用できます。

メリットと応用

Jetpack をコンポーネントとして切り出すことで、以下のようなメリットがあります。

  • プレハブの再利用性が高い
    • プレイヤー用、敵用、ギミック用など、どのオブジェクトにも同じ Jetpack をアタッチするだけで「燃料付きの上昇機能」が手に入る
    • パラメータ(thrustForce, maxFuel など)をプレハブごとに変えれば、「重いジェット」「軽いジェット」などバリエーションも簡単に作れる
  • レベルデザインが楽になる
    • 特定のステージだけ「燃料の少ないジェットパック」を使わせる、などをプレハブ差し替えで実現できる
    • 空中に浮かぶ敵やギミックも、JetpackalwaysThrust を ON にして使えば、「常に上昇する謎オブジェクト」などを簡単に作成可能
  • Godクラスを避けられる
    • プレイヤー制御スクリプトから「ジャンプ」「移動」「ジェットパック」「アニメーション」などの責務を分離できる
    • 不具合が出たときも、どのコンポーネントの責任か切り分けやすい

改造案:燃料が切れたときに自動的に噴射を止める演出

燃料が0になった瞬間に、エフェクト停止やサウンド停止などを行いたい場合、以下のようなメソッドを Jetpack に追加してみましょう。


    /// <summary>
    /// 燃料が尽きた瞬間に呼び出されるフック。
    /// エフェクト停止や警告サウンド再生などをここに実装できます。
    /// </summary>
    private void OnFuelDepleted()
    {
        // 例: 噴射入力を強制的にオフにする
        isThrusting = false;

        // 例: デバッグログ(実際のゲームではここでSEやパーティクル制御など)
        Debug.Log("[Jetpack] Fuel depleted. Jetpack disabled temporarily.", this);
    }

そして ConsumeFuel 内で、燃料が 0 になった瞬間にこのメソッドを呼び出すように変更すれば、「燃料切れ演出」を簡単に追加できます。こうした小さな改造ポイントを用意しておくと、後からの演出強化もスムーズに進められますね。

このように、ジェットパックという1つの機能だけでも、コンポーネントとしてきちんと切り出しておくと、プロジェクト全体の見通しがかなり良くなります。ぜひ自分のプロジェクトでも、「1クラス1責務」を意識したコンポーネント設計を試してみてください。