Unityを触り始めた頃は、つい Update() に「移動」「攻撃」「エフェクト」「UI更新」「振動処理」などを全部詰め込んでしまいがちですよね。
とりあえず動くので満足してしまうのですが、時間が経つと「どこで何をしているのか分からない巨大スクリプト(Godクラス)」になり、バグ修正や機能追加のたびに地獄を見ることになります。

特にゲームパッドの振動(Rumble)は、Update() に直接 Gamepad.current.SetMotorSpeeds() を書き散らかすと、

  • ダメージ時の振動
  • 着地時の振動
  • 必殺技発動時の振動

などが全部バラバラの場所に書かれてしまい、「どこかで振動が止まらない」「別の処理が振動を上書きしてしまう」といった問題が起きやすくなります。

そこで今回は、「振動だけ」を責務に持つ小さなコンポーネントとして、RumbleManager を用意して、
「ダメージを受けた」「高いところから着地した」といったイベント側からは、
RumbleManager に「振動して!」と依頼するだけの形に分離してみましょう。

【Unity】イベント駆動で気持ちよく振動!「RumbleManager」コンポーネント

以下は Unity6(新Input System前提)で、ダメージ・着地などから簡単に呼び出せる汎用的な振動制御コンポーネントです。
1つのゲームオブジェクトにアタッチしておき、他のスクリプトから PlayRumble() / PlayDamageRumble() などを呼び出して使います。

フルコード


using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem; // 新Input System用

/// <summary>
/// ゲームパッド振動を一元管理するコンポーネント。
/// - 複数の「振動リクエスト」をキューとして管理
/// - 時間経過で自動停止
/// - ダメージ/着地など、用途別のプリセットAPIを用意
/// 
/// 想定環境:
/// - Unity 6
/// - Input System パッケージ使用
/// - 使用前に「Edit > Project Settings > Input System Package」設定を済ませておくこと
/// </summary>
public class RumbleManager : MonoBehaviour
{
    // ==============================
    // 設定項目(インスペクターで調整)
    // ==============================

    [Header("デフォルト振動パラメータ")]
    [Tooltip("特に指定がないときに使う振動の長さ(秒)")]
    [SerializeField] private float defaultDuration = 0.3f;

    [Tooltip("特に指定がないときの低周波モーター強度(0〜1)")]
    [SerializeField, Range(0f, 1f)] private float defaultLowFrequency = 0.5f;

    [Tooltip("特に指定がないときの高周波モーター強度(0〜1)")]
    [SerializeField, Range(0f, 1f)] private float defaultHighFrequency = 0.5f;

    [Header("プリセット:ダメージ時")]
    [SerializeField] private float damageDuration = 0.25f;
    [SerializeField, Range(0f, 1f)] private float damageLowFrequency = 0.7f;
    [SerializeField, Range(0f, 1f)] private float damageHighFrequency = 1.0f;

    [Header("プリセット:着地時")]
    [SerializeField] private float landingDuration = 0.18f;
    [SerializeField, Range(0f, 1f)] private float landingLowFrequency = 0.9f;
    [SerializeField, Range(0f, 1f)] private float landingHighFrequency = 0.3f;

    [Header("その他設定")]
    [Tooltip("シーン内に複数存在する場合、どれか1つをグローバルに参照できるようにするか")]
    [SerializeField] private bool useAsSingleton = true;

    [Tooltip("ゲームパッドが見つからないときに警告ログを出すか")]
    [SerializeField] private bool logWarningIfNoGamepad = true;

    // ==============================
    // 内部状態
    // ==============================

    /// <summary>
    /// 1つの振動リクエストを表す構造体
    /// </summary>
    private struct RumbleRequest
    {
        public float remainingTime;   // 残り時間(秒)
        public float lowFrequency;    // 低周波モーター強度
        public float highFrequency;   // 高周波モーター強度;
        public float priority;        // 優先度(大きいほど優先)
    }

    // 現在アクティブな振動リクエストたち
    private readonly List<RumbleRequest> _requests = new List<RumbleRequest>();

    // シングルトン的に参照したいとき用
    public static RumbleManager Instance { get; private set; }

    // 直近のフレームで適用したモーター値(無駄なSetMotorSpeeds呼び出しを避ける)
    private float _appliedLow;
    private float _appliedHigh;

    // キャッシュ用:現在のGamepad
    private Gamepad CurrentGamepad => Gamepad.current;

    // ==============================
    // ライフサイクル
    // ==============================

    private void Awake()
    {
        if (useAsSingleton)
        {
            if (Instance != null && Instance != this)
            {
                // 複数存在した場合は警告を出して、後から生成された方を破棄
                Debug.LogWarning(
                    $"[RumbleManager] すでにInstanceが存在します。重複したRumbleManagerを削除します。({gameObject.name})");
                Destroy(this);
                return;
            }

            Instance = this;
        }
    }

    private void OnDisable()
    {
        // コンポーネントが無効化されたら、振動も止めておく
        StopAllRumble();
    }

    private void OnDestroy()
    {
        if (Instance == this)
        {
            Instance = null;
        }

        // オブジェクト破棄時にも念のため停止
        StopAllRumble();
    }

    private void Update()
    {
        // ゲームパッドが接続されていなければ、なにもしない
        var gamepad = CurrentGamepad;
        if (gamepad == null)
        {
            if (logWarningIfNoGamepad && _requests.Count > 0)
            {
                Debug.LogWarning("[RumbleManager] Gamepad が見つかりません。振動リクエストは無視されます。");
            }

            // リクエストだけは時間経過で削除しておく
            UpdateRequestsWithoutOutput();
            return;
        }

        // リクエストを時間経過で更新しつつ、最終的な出力値を決定
        UpdateRequestsAndApplyTo(gamepad);
    }

    // ==============================
    // パブリックAPI:汎用
    // ==============================

    /// <summary>
    /// 任意のパラメータで振動させる基本API。
    /// duration 秒間、指定した強度で振動させます。
    /// </summary>
    /// <param name="duration">振動時間(秒)</param>
    /// <param name="lowFrequency">低周波モーター強度 0〜1</param>
    /// <param name="highFrequency">高周波モーター強度 0〜1</param>
    /// <param name="priority">
    /// 優先度。値が大きいほど優先されます。
    /// 例: 通常 0, ダメージ 10, 必殺技 100 など。
    /// </param>
    public void PlayRumble(
        float duration,
        float lowFrequency,
        float highFrequency,
        float priority = 0f)
    {
        if (duration <= 0f)
        {
            return;
        }

        var clampedLow = Mathf.Clamp01(lowFrequency);
        var clampedHigh = Mathf.Clamp01(highFrequency);

        var request = new RumbleRequest
        {
            remainingTime = duration,
            lowFrequency = clampedLow,
            highFrequency = clampedHigh,
            priority = priority
        };

        _requests.Add(request);
    }

    /// <summary>
    /// デフォルト設定で振動させる簡易API。
    /// </summary>
    public void PlayDefaultRumble(float priority = 0f)
    {
        PlayRumble(defaultDuration, defaultLowFrequency, defaultHighFrequency, priority);
    }

    // ==============================
    // パブリックAPI:用途別プリセット
    // ==============================

    /// <summary>
    /// ダメージを受けたときに呼ぶ想定の振動。
    /// 強め・短めのビリッとした振動。
    /// </summary>
    public void PlayDamageRumble()
    {
        // ダメージはやや高めの優先度にしておく
        const float damagePriority = 10f;
        PlayRumble(damageDuration, damageLowFrequency, damageHighFrequency, damagePriority);
    }

    /// <summary>
    /// 高いところから着地したときなどに呼ぶ想定の振動。
    /// 低周波寄りで「ドスッ」とくる感じ。
    /// </summary>
    public void PlayLandingRumble()
    {
        const float landingPriority = 5f;
        PlayRumble(landingDuration, landingLowFrequency, landingHighFrequency, landingPriority);
    }

    // ==============================
    // パブリックAPI:停止系
    // ==============================

    /// <summary>
    /// すべての振動リクエストを破棄し、即座に振動を止める。
    /// </summary>
    public void StopAllRumble()
    {
        _requests.Clear();
        ApplyMotorSpeeds(0f, 0f);
    }

    // ==============================
    // 内部処理:Update用
    // ==============================

    /// <summary>
    /// Gamepadが存在しない場合用。リクエストの時間だけ減らして削除する。
    /// </summary>
    private void UpdateRequestsWithoutOutput()
    {
        if (_requests.Count == 0)
        {
            return;
        }

        float deltaTime = Time.unscaledDeltaTime;

        for (int i = _requests.Count - 1; i >= 0; i--)
        {
            var r = _requests[i];
            r.remainingTime -= deltaTime;
            if (r.remainingTime <= 0f)
            {
                _requests.RemoveAt(i);
            }
            else
            {
                _requests[i] = r;
            }
        }

        // 出力は常に0
        _appliedLow = 0f;
        _appliedHigh = 0f;
    }

    /// <summary>
    /// リクエストを更新し、最終的な振動値をGamepadに適用する。
    /// </summary>
    private void UpdateRequestsAndApplyTo(Gamepad gamepad)
    {
        if (_requests.Count == 0)
        {
            // リクエストがなければ振動を止める
            ApplyMotorSpeeds(0f, 0f);
            return;
        }

        float deltaTime = Time.unscaledDeltaTime;

        // 1. 時間経過でリクエストを減衰・削除
        for (int i = _requests.Count - 1; i >= 0; i--)
        {
            var r = _requests[i];
            r.remainingTime -= deltaTime;
            if (r.remainingTime <= 0f)
            {
                _requests.RemoveAt(i);
            }
            else
            {
                _requests[i] = r;
            }
        }

        if (_requests.Count == 0)
        {
            ApplyMotorSpeeds(0f, 0f);
            return;
        }

        // 2. 優先度の高いリクエストほど強く影響するように合成
        float finalLow = 0f;
        float finalHigh = 0f;
        float maxPriority = 0f;

        foreach (var r in _requests)
        {
            // 優先度に応じて重み付け
            float weight = Mathf.Max(0f, r.priority);
            if (weight == 0f)
            {
                // 優先度0は「通常扱い」としてそのまま加算
                finalLow = Mathf.Max(finalLow, r.lowFrequency);
                finalHigh = Mathf.Max(finalHigh, r.highFrequency);
                continue;
            }

            // 優先度が高いほど上書きしやすくする
            if (r.priority >= maxPriority)
            {
                maxPriority = r.priority;
                finalLow = Mathf.Max(finalLow, r.lowFrequency);
                finalHigh = Mathf.Max(finalHigh, r.highFrequency);
            }
        }

        finalLow = Mathf.Clamp01(finalLow);
        finalHigh = Mathf.Clamp01(finalHigh);

        ApplyMotorSpeeds(finalLow, finalHigh);
    }

    /// <summary>
    /// 実際にGamepadにモーター値を適用する。
    /// 同じ値で何度も呼ばないように最適化。
    /// </summary>
    private void ApplyMotorSpeeds(float low, float high)
    {
        // すでに同じ値が適用されているなら何もしない
        if (Mathf.Approximately(_appliedLow, low) && Mathf.Approximately(_appliedHigh, high))
        {
            return;
        }

        _appliedLow = low;
        _appliedHigh = high;

        var gamepad = CurrentGamepad;
        if (gamepad == null)
        {
            return;
        }

        gamepad.SetMotorSpeeds(low, high);
    }
}

使い方の手順

  1. シーンに RumbleManager を配置する
    • 空の GameObject を作成して名前を RumbleManager にする。
    • 上のスクリプトを RumbleManager.cs として保存し、その GameObject にアタッチ。
    • インスペクターで「ダメージ」「着地」のプリセット値をお好みで調整。
    • 「Use As Singleton」にチェックを入れておくと、どこからでも RumbleManager.Instance で呼び出せます。
  2. プレイヤーのダメージ処理から呼び出す
    例として、プレイヤーが敵に当たったときに振動させるコードはこんな感じです。
    
    using UnityEngine;
    
    public class PlayerHealth : MonoBehaviour
    {
        [SerializeField] private int maxHp = 100;
        private int _currentHp;
    
        private void Awake()
        {
            _currentHp = maxHp;
        }
    
        public void TakeDamage(int amount)
        {
            _currentHp -= amount;
            if (_currentHp <= 0)
            {
                _currentHp = 0;
                // TODO: 死亡処理
            }
    
            // ダメージを受けたので振動させる
            if (RumbleManager.Instance != null)
            {
                RumbleManager.Instance.PlayDamageRumble();
            }
        }
    }
    

    振動の具体的なパラメータは RumbleManager 側に閉じ込めているので、
    ダメージ処理は「ダメージを受けた」ことだけに集中できます。

  3. 着地判定から呼び出す(2D/3Dどちらでも応用可)
    高いところから落ちたときにだけ「ドスッ」と振動させる例です。
    
    using UnityEngine;
    
    [RequireComponent(typeof(Rigidbody))]
    public class LandingDetector : MonoBehaviour
    {
        [SerializeField] private float minImpactVelocity = 5f;
    
        private Rigidbody _rb;
        private bool _wasGrounded;
    
        private void Awake()
        {
            _rb = GetComponent<Rigidbody>();
        }
    
        private void OnCollisionEnter(Collision collision)
        {
            // 下方向にある程度の速度でぶつかったら着地とみなす
            if (Vector3.Dot(_rb.velocity.normalized, Vector3.down) < 0.5f)
            {
                return;
            }
    
            if (_rb.velocity.magnitude >= minImpactVelocity)
            {
                if (RumbleManager.Instance != null)
                {
                    RumbleManager.Instance.PlayLandingRumble();
                }
            }
        }
    }
    

    こうしておくと、「着地のロジック」と「振動のロジック」がきれいに分離されて保守しやすくなります。

  4. 任意のイベントからカスタム振動を鳴らす
    必殺技ボタンを押したときに、長め・強めの振動を鳴らしたい場合は、
    どこからでも以下のように書けます。
    
    if (RumbleManager.Instance != null)
    {
        // duration: 0.8秒, low: 0.8, high: 0.8, priority: 100(かなり高い)
        RumbleManager.Instance.PlayRumble(0.8f, 0.8f, 0.8f, 100f);
    }
    

    プレイヤー、敵、ギミック(動く床やトラップ)など、
    「ここで振動させたい」と思った場所から同じAPIを呼ぶだけで統一した挙動になります。

メリットと応用

RumbleManager のように振動を専用コンポーネントに切り出しておくと、次のようなメリットがあります。

  • 巨大な Update 地獄からの脱出
    プレイヤー制御スクリプトの中に Gamepad.current.SetMotorSpeeds() を直接書かないので、
    移動や攻撃ロジックと振動ロジックが混ざらず、読みやすくなります。
  • プレハブの再利用性が上がる
    プレイヤー、敵、ギミックなどのプレハブは「自分のイベントが起きたら RumbleManager に通知する」だけにしておけば、
    振動の強さやパターンを変えたくなったときは RumbleManager のインスペクターをいじるだけで済みます。
    いちいち全プレハブのスクリプトを書き換える必要がありません。
  • レベルデザインが楽になる
    「このトラップはもう少し弱い振動にしたい」「ボス戦の必殺技だけ強烈にしたい」といった調整も、
    レベルデザイナーがインスペクターの数値を触るだけで完結します。
    コードに手を入れなくてもフィードバック調整ができるのは大きな利点ですね。
  • 優先度制御で「気持ちよさ」を崩さない
    強い演出(必殺技など)は高い priority を設定しておくことで、
    その最中に小さなダメージを受けても、ビリビリ演出が中途半端に上書きされないようにできます。

さらに、ちょっとした改造で「フェードアウト」や「パルス振動」などにも対応できます。
例えば、現在の振動を一瞬だけ強くしてからスッと落とす「ショック」演出を追加したい場合、
以下のようなヘルパーメソッドを RumbleManager に足すのもありです。


/// <summary>
/// 現在の振動に「ショック」を重ねる例。
/// 強い振動を短時間鳴らし、その後少し弱くして余韻を残す。
/// </summary>
public void PlayShockWave(float baseDuration = 0.15f)
{
    // 一瞬だけ強く
    PlayRumble(baseDuration * 0.4f, 1.0f, 1.0f, priority: 50f);

    // そのあと少し弱く余韻
    PlayRumble(baseDuration * 0.6f, 0.4f, 0.4f, priority: 20f);
}

このように、振動の知識は RumbleManager に集約しておき、
各コンポーネントは「どのタイミングでどのプリセットを鳴らすか」だけを考えるようにすると、
プロジェクト全体がすっきりしたコンポーネント指向の構成になります。