Unityを触り始めた頃、「とりあえず全部 Update に書いて動けばOK!」という実装をしがちですよね。プレイヤー操作、エネミーAI、エフェクト制御、スコア管理……全部が1つのスクリプトに詰め込まれていくと、少しずつ変更がしづらくなり、最終的には手が付けられない God クラスになってしまいます。

物理挙動やギミックも同じで、「特定エリアに入ったら吸い込まれる」「重力が変わる」などを全部プレイヤー側の Update で if 文だらけにしてしまうと、プレイヤーのスクリプトがどんどん肥大化します。

そこで今回は、「ブラックホールのように周囲の Rigidbody を中心に吸い込む」という機能だけに責務を絞ったコンポーネント BlackHole を作ってみましょう。任意のオブジェクトにアタッチするだけで、周囲の物体をグイグイ引き寄せるエリアを簡単に作れるようになります。

【Unity】重力井戸でオブジェクトを吸い込む!「BlackHole」コンポーネント

フルコード(BlackHole.cs)


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/**
 * BlackHole
 * 周囲の Rigidbody を中心に向かって吸い込むコンポーネント。
 * - 一定半径内の Rigidbody に対して、中心方向の力を加えます。
 * - 力の強さ、減衰の仕方、対象レイヤーなどをインスペクターから調整できます。
 * 
 * 使い方:
 * 1. 空の GameObject を作成し、このスクリプトをアタッチ。
 * 2. SphereCollider を追加し、「Is Trigger」にチェックを入れる。
 * 3. 吸い込み半径 = SphereCollider の Radius として扱うのが分かりやすいです。
 */
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(SphereCollider))]
public class BlackHole : MonoBehaviour
{
    [Header("基本設定")]

    [SerializeField]
    [Tooltip("ブラックホールの有効/無効を切り替えます。")]
    private bool _isActive = true;

    [SerializeField]
    [Tooltip("ブラックホールの中心(未指定なら自分自身の Transform を使用)。")]
    private Transform _center;

    [SerializeField]
    [Tooltip("中心へ向かう力の強さ(1秒あたりの加速度のようなイメージ)。")]
    private float _forceStrength = 50f;

    [SerializeField]
    [Tooltip("距離による減衰カーブ。X=0 が中心、X=1 が最大半径(コライダー半径)。Y が力の倍率。")]
    private AnimationCurve _distanceFalloff = AnimationCurve.EaseInOut(0f, 1f, 1f, 0f);

    [SerializeField]
    [Tooltip("影響を与える最大半径。通常は SphereCollider の Radius と同じにすると直感的です。")]
    private float _maxRadius = 5f;

    [SerializeField]
    [Tooltip("このレイヤーマスクに含まれる Rigidbody のみ吸い込み対象にします。")]
    private LayerMask _affectedLayers = ~0; // デフォルトですべて

    [SerializeField]
    [Tooltip("Trigger 内にいる Rigidbody をキャッシュして効率的に処理するかどうか。")]
    private bool _useTriggerTracking = true;

    [Header("安定性・演出オプション")]

    [SerializeField]
    [Tooltip("中心に近づいたときの最大速度(0以下で制限なし)。")]
    private float _maxSpeed = 0f;

    [SerializeField]
    [Tooltip("Rigidbody の現在速度を徐々に減衰させる割合(0 で無効)。")]
    [Range(0f, 1f)]
    private float _velocityDamping = 0.1f;

    [SerializeField]
    [Tooltip("2D ゲームの場合は true にして、Y を無視した XZ 平面の吸い込みにできます。")]
    private bool _flattenToXZPlane = false;

    // Trigger 内の Rigidbody を追跡するためのセット
    private readonly HashSet _trackedBodies = new HashSet();

    // ワーク用のリスト(foreach 中にコレクションを変更しないためのバッファ)
    private readonly List _removalBuffer = new List();

    private SphereCollider _sphereCollider;

    private void Reset()
    {
        // コンポーネント追加時にデフォルト設定を整える
        _sphereCollider = GetComponent<SphereCollider>();
        _sphereCollider.isTrigger = true;
        _sphereCollider.radius = _maxRadius;

        _center = transform;
    }

    private void Awake()
    {
        _sphereCollider = GetComponent<SphereCollider>();

        if (_center == null)
        {
            _center = transform;
        }

        // コライダー半径と maxRadius がずれている場合、maxRadius を優先して反映
        _sphereCollider.isTrigger = true;
        _sphereCollider.radius = _maxRadius;
    }

    private void FixedUpdate()
    {
        if (!_isActive)
        {
            return;
        }

        if (_useTriggerTracking)
        {
            // Trigger 内の Rigidbody を対象に力を加える
            ApplyForceToTrackedBodies();
        }
        else
        {
            // Physics.OverlapSphere を使って毎フレーム検索するモード
            ApplyForceByOverlapSphere();
        }
    }

    /// <summary>
    /// Trigger 内に入っている Rigidbody たちに力を適用する。
    /// </summary>
    private void ApplyForceToTrackedBodies()
    {
        if (_trackedBodies.Count == 0)
        {
            return;
        }

        _removalBuffer.Clear();

        foreach (var body in _trackedBodies)
        {
            if (body == null)
            {
                // 破棄された Rigidbody をリストから削除するためにバッファに追加
                _removalBuffer.Add(body);
                continue;
            }

            // レイヤーマスクでフィルタリング
            if (((1 << body.gameObject.layer) & _affectedLayers.value) == 0)
            {
                continue;
            }

            ApplyBlackHoleForce(body);
        }

        // 破棄された Rigidbody をセットから一括削除
        foreach (var body in _removalBuffer)
        {
            _trackedBodies.Remove(body);
        }
    }

    /// <summary>
    /// OverlapSphere で毎フレーム検索して力を適用する。
    /// </summary>
    private void ApplyForceByOverlapSphere()
    {
        Vector3 centerPos = _center.position;

        // Sphere 内にある Collider をすべて取得
        Collider[] hits = Physics.OverlapSphere(centerPos, _maxRadius, _affectedLayers, QueryTriggerInteraction.Ignore);

        foreach (var hit in hits)
        {
            if (!hit.attachedRigidbody)
            {
                continue;
            }

            Rigidbody body = hit.attachedRigidbody;
            ApplyBlackHoleForce(body);
        }
    }

    /// <summary>
    /// 1つの Rigidbody に対してブラックホールの力を適用する。
    /// </summary>
    private void ApplyBlackHoleForce(Rigidbody body)
    {
        Vector3 centerPos = _center.position;
        Vector3 bodyPos = body.position;

        // 吸い込み方向(中心 - 対象)
        Vector3 direction = centerPos - bodyPos;

        if (_flattenToXZPlane)
        {
            // Y 成分を無視して、XZ 平面だけで吸い込む
            direction.y = 0f;
        }

        float distance = direction.magnitude;

        if (distance <= 0.0001f)
        {
            // ほぼ中心にいる場合は何もしない
            return;
        }

        // 正規化された方向ベクトル
        Vector3 normalizedDir = direction / distance;

        // 0〜1 に正規化した距離
        float normalizedDistance = Mathf.Clamp01(distance / _maxRadius);

        // 距離に応じた減衰係数(AnimationCurve)
        float falloff = _distanceFalloff.Evaluate(normalizedDistance);

        // 実際に適用する力の大きさ
        float forceMagnitude = _forceStrength * falloff;

        // AddForce は質量を考慮した「加速度」的なイメージで使用(ForceMode.Acceleration)
        Vector3 force = normalizedDir * forceMagnitude;
        body.AddForce(force, ForceMode.Acceleration);

        // 速度減衰(ブラックホールに吸い込まれている感じを強調)
        if (_velocityDamping > 0f)
        {
            body.velocity *= (1f - _velocityDamping * Time.fixedDeltaTime);
        }

        // 最大速度の制限
        if (_maxSpeed > 0f)
        {
            float sqrMaxSpeed = _maxSpeed * _maxSpeed;
            if (body.velocity.sqrMagnitude > sqrMaxSpeed)
            {
                body.velocity = body.velocity.normalized * _maxSpeed;
            }
        }
    }

    #region Trigger Tracking

    private void OnTriggerEnter(Collider other)
    {
        if (!_useTriggerTracking)
        {
            return;
        }

        // IsTrigger なコライダー同士の衝突も来るので、Rigidbody が付いているかを確認
        Rigidbody body = other.attachedRigidbody;
        if (body == null)
        {
            return;
        }

        // レイヤーマスクでフィルタリング
        if (((1 << body.gameObject.layer) & _affectedLayers.value) == 0)
        {
            return;
        }

        _trackedBodies.Add(body);
    }

    private void OnTriggerExit(Collider other)
    {
        if (!_useTriggerTracking)
        {
            return;
        }

        Rigidbody body = other.attachedRigidbody;
        if (body == null)
        {
            return;
        }

        _trackedBodies.Remove(body);
    }

    private void OnDisable()
    {
        // 無効化されたら一旦キャッシュをクリア
        _trackedBodies.Clear();
    }

    #endregion

    #region 公開API(ゲーム側から制御したいとき用)

    /// <summary>
    /// ブラックホールの ON/OFF を切り替えます。
    /// </summary>
    public void SetActive(bool isActive)
    {
        _isActive = isActive;
    }

    /// <summary>
    /// 吸い込みの強さを変更します。
    /// </summary>
    public void SetForceStrength(float strength)
    {
        _forceStrength = Mathf.Max(0f, strength);
    }

    /// <summary>
    /// 吸い込み半径を変更し、SphereCollider にも反映します。
    /// </summary>
    public void SetRadius(float radius)
    {
        _maxRadius = Mathf.Max(0.01f, radius);
        if (_sphereCollider != null)
        {
            _sphereCollider.radius = _maxRadius;
        }
    }

    #endregion
}

使い方の手順

  1. シーンにブラックホール本体を作る
    – Hierarchy で Right Click > Create Empty を選び、名前を BlackHole にします。
    – 位置は吸い込みの中心にしたい場所に配置しましょう(例:ステージ中央)。
  2. コンポーネントをアタッチして設定する
    – 作成した GameObject に BlackHole.cs をアタッチします。
    – 自動で SphereCollider が追加され、Is Trigger が有効になります。
    Max Radius を 5〜10 くらいに設定すると、吸い込み範囲が分かりやすいです。
    Affected Layers で「Player」「Enemy」「Default」など、吸い込みたいレイヤーだけに絞ると便利です。
  3. 吸い込まれるオブジェクトを用意する
    – 例として「プレイヤー」や「敵」「物理オブジェクト(箱など)」のプレハブに Rigidbody を追加します。
    – 「プレイヤー」は通常どおり移動スクリプトを持っていてOKです。
    – レイヤーを PlayerEnemy に設定し、BlackHole の Affected Layers に含まれるようにします。
  4. 実行して挙動を確認する
    – 再生して、Rigidbody を持つオブジェクトをブラックホールの半径内に入れてみましょう。
    – 中心に向かって加速しながら吸い込まれていくはずです。
    Force Strength を上げると吸い込みが強くなり、Distance Falloff のカーブを変えると「中心付近だけ超強力」「外側からじわじわ」など好みの挙動に調整できます。

具体例として、次のような使い方ができます。

  • プレイヤー用ブラックホールトラップ
    ステージの穴の上に BlackHole を置き、Affected LayersPlayer のみにすることで、プレイヤーだけを吸い込むトラップを実現できます。敵は無視されるので、レベルデザインの幅が広がります。
  • 敵をまとめる重力球
    敵 AI は通常どおり動きつつ、定期的に BlackHole をスポーンさせて敵を中央に集める、といったギミックが作れます。
    Use Trigger Tracking をオンにしておくと、パフォーマンスにも優しいです。
  • 動く床に設置して「重力コンベア」にする
    移動する足場(Moving Platform)の子オブジェクトとして BlackHole を置くと、足場の周囲の箱やプレイヤーを足場の中心に寄せる「重力付きコンベア」のような挙動を作れます。
    Flatten To XZ Plane をオンにしておくと、Y 方向には影響せず、横方向だけ吸い寄せることもできます。

メリットと応用

この BlackHole コンポーネントの良いところは、「吸い込み」という1つの責務に特化している点です。移動ロジックや HP 管理、エフェクト再生などは他のコンポーネントに任せて、BlackHole は「物理的に引き寄せる」ことだけを担当します。

その結果、次のようなメリットがあります。

  • プレハブの再利用性が高い
    ブラックホールの見た目(パーティクル、メッシュ、マテリアル)とは完全に分離されているので、
    – 見た目だけ違うブラックホール
    – 吸い込み強度・半径だけ違うバリエーション
    をプレハブとして簡単に量産できます。
  • レベルデザインが直感的になる
    レベルデザイナーは「ここにブラックホール置きたいな」と思った場所に、このプレハブをポンと置くだけでOKです。
    スクリプトを触らずに、インスペクターのパラメータ調整だけでゲームバランスを変えられるのが大きな利点です。
  • 他コンポーネントとの組み合わせがしやすい
    BlackHole 自体は Rigidbody には触りますが、「死ぬ」「ダメージを受ける」などのゲームルールには関与しません。
    そのため、Health コンポーネントや DestroyOnCenterReached のような別コンポーネントを追加するだけで、簡単に「ブラックホールに飲み込まれて消える」演出を作れます。

応用として、例えば「一定距離まで近づいたらオブジェクトを即座に破壊する」機能を別コンポーネントとして追加するのも良いですね。以下のようなシンプルなコンポーネントをブラックホールにアタッチすれば、中心に到達したオブジェクトを自動で消すことができます。


using UnityEngine;

/// <summary>
/**
 * BlackHoleKillZone
 * ブラックホール中心付近に到達した Rigidbody を破壊するコンポーネント。
 * - BlackHole と同じ GameObject にアタッチして使う想定です。
 */
/// </summary>
[DisallowMultipleComponent]
public class BlackHoleKillZone : MonoBehaviour
{
    [SerializeField]
    [Tooltip("この距離以内に入った Rigidbody を破壊します。")]
    private float _killRadius = 0.5f;

    [SerializeField]
    [Tooltip("破壊対象とするレイヤー。")]
    private LayerMask _targetLayers = ~0;

    private void OnTriggerStay(Collider other)
    {
        Rigidbody body = other.attachedRigidbody;
        if (body == null)
        {
            return;
        }

        if (((1 << body.gameObject.layer) & _targetLayers.value) == 0)
        {
            return;
        }

        float distance = Vector3.Distance(transform.position, body.position);
        if (distance <= _killRadius)
        {
            // ここでゲーム固有の「死亡処理」に差し替えてもOK
            Destroy(body.gameObject);
        }
    }
}

このように、1つのコンポーネントに何でもかんでも詰め込まず、「吸い込む」「中心で破壊する」のように小さく分けておくと、後からの差し替えや拡張がとても楽になります。BlackHole をベースに、回転させたり、音を鳴らしたり、色を変えたりと、好みの重力ギミックに育てていきましょう。