Unityを触り始めたころは、なんでもかんでも Update() に書きがちですよね。プレイヤーの移動、カメラ追従、エフェクト制御、そして今回のような「うねる触手」の動きまで、全部ひとつのスクリプトで頑張ろうとすると、あっという間に巨大なGodクラスが出来上がってしまいます。

そうなると、

  • どこを直せばバグが直るのか分からない
  • 1つの機能を変更したいだけなのに、他の挙動まで壊れる
  • プレハブとして再利用しづらい(コピペ地獄)

といった問題が出てきます。

そこでこの記事では、「触手のような多関節オブジェクト」を扱うロジックをひとつの責務に切り出した、TentacleLimb コンポーネントを用意します。複数のスプライトをJointでつなぎ、Rigidbody2Dの物理演算を使って、自然にうねる触手を表現できるようにしていきましょう。

【Unity】物理でうねる多関節触手!「TentacleLimb」コンポーネント

このコンポーネントは「触手全体」を制御する役割に限定し、

  • 各セグメントのRigidbody2D / Joint2D を自動接続
  • 先端や根本をターゲットに向けて「追従させる力」を加える
  • パラメータ(長さ・柔らかさ・追従強度)をインスペクターから調整

といったことだけを担当します。セグメントごとの見た目や攻撃判定などは、別コンポーネントに切り出せるようにしておくことで、コンポーネント指向な設計を維持できます。

フルソースコード


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 2D物理ベースの多関節触手コンポーネント。
/// 複数のセグメント(Rigidbody2D + Joint2D)をつなぎ、
/// 先端または根本をターゲットに追従させることで、
/// うねる触手のような動きを実現します。
/// 
/// 想定構成(2D):
/// - 親オブジェクト: TentacleLimb をアタッチ
///   - 子: Segment0 (Rigidbody2D + SpriteRenderer)
///   - 子: Segment1 (Rigidbody2D + SpriteRenderer)
///   - 子: Segment2 ...
/// 各セグメント間はこのスクリプトが自動で HingeJoint2D を付与・接続します。
/// </summary>
[DisallowMultipleComponent]
public class TentacleLimb : MonoBehaviour
{
    // ================================
    // 設定パラメータ
    // ================================

    [Header("セグメント設定")]
    [Tooltip("触手を構成するセグメント(根本から先端へ)の Rigidbody2D を順番に登録します。\n" +
             "空の場合は、子オブジェクトから自動検出します。")]
    [SerializeField] private List<Rigidbody2D> segmentBodies = new List<Rigidbody2D>();

    [Tooltip("各セグメント間の距離(ローカル座標)。自動検出時の配置に使用します。")]
    [SerializeField] private float segmentSpacing = 0.5f;

    [Header("Joint 設定")]
    [Tooltip("セグメント間をつなぐジョイントのバネの強さ。大きいほど硬く、まっすぐになりやすいです。")]
    [SerializeField] private float jointFrequency = 5f;

    [Tooltip("セグメント間をつなぐジョイントのダンピング比。0~1 の範囲推奨。\n" +
             "大きいほど振動が早く収束します。")]
    [Range(0f, 1f)]
    [SerializeField] private float jointDampingRatio = 0.5f;

    [Tooltip("ジョイントの角度制限を有効にするかどうか。")]
    [SerializeField] private bool useAngleLimits = true;

    [Tooltip("ジョイントの最大回転角度(±値)")]
    [SerializeField] private float jointAngleLimit = 60f;

    [Header("追従ターゲット設定")]
    [Tooltip("触手の先端を追従させたいターゲット。例: プレイヤー、カーソル、エサなど")]
    [SerializeField] private Transform tipTarget;

    [Tooltip("触手の根本を追従させたいターゲット。例: 本体、ボスの胴体など")]
    [SerializeField] private Transform rootTarget;

    [Tooltip("ターゲット追従の強さ。大きいほど素早く追従しますが、不安定になりやすいです。")]
    [SerializeField] private float followForce = 10f;

    [Tooltip("ターゲットとの距離がこの値以下のときは追従力を弱めます。")]
    [SerializeField] private float followStopDistance = 0.2f;

    [Header("動きのチューニング")]
    [Tooltip("触手全体の柔らかさスケール。1 で標準、0.5 でふにゃふにゃ、2 で硬め。")]
    [SerializeField] private float softness = 1f;

    [Tooltip("重力をどの程度影響させるか。0 で無視、1 で通常の重力。")]
    [Range(0f, 1f)]
    [SerializeField] private float gravityScale = 1f;

    [Tooltip("デバッグ用 Gizmo を表示するかどうか。")]
    [SerializeField] private bool drawGizmos = true;

    // 内部キャッシュ
    private readonly List<HingeJoint2D> _joints = new List<HingeJoint2D>();

    // ================================
    // Unity イベント
    // ================================

    private void Reset()
    {
        // コンポーネントが追加されたときに、子からセグメントを自動検出します。
        AutoCollectSegmentsFromChildren();
    }

    private void Awake()
    {
        // セグメントが設定されていなければ、自動検出を試みる
        if (segmentBodies == null || segmentBodies.Count == 0)
        {
            AutoCollectSegmentsFromChildren();
        }

        // Joint の初期化
        InitializeJoints();
    }

    private void FixedUpdate()
    {
        // 物理演算に関わる処理は FixedUpdate で行います。
        ApplyGravityScale();
        ApplyFollowForces();
    }

    private void OnDrawGizmos()
    {
        if (!drawGizmos) return;

        // セグメント間を線でつなぐ
        if (segmentBodies != null && segmentBodies.Count > 1)
        {
            Gizmos.color = Color.cyan;
            for (int i = 0; i < segmentBodies.Count - 1; i++)
            {
                if (segmentBodies[i] == null || segmentBodies[i + 1] == null) continue;
                Gizmos.DrawLine(segmentBodies[i].position, segmentBodies[i + 1].position);
            }
        }

        // ターゲット位置を表示
        Gizmos.color = Color.yellow;
        if (tipTarget != null)
        {
            Gizmos.DrawWireSphere(tipTarget.position, 0.1f);
        }

        Gizmos.color = Color.magenta;
        if (rootTarget != null)
        {
            Gizmos.DrawWireSphere(rootTarget.position, 0.1f);
        }
    }

    // ================================
    // 初期化系
    // ================================

    /// <summary>
    /// 子オブジェクトから Rigidbody2D を自動検出して、セグメントリストを構築します。
    /// </summary>
    private void AutoCollectSegmentsFromChildren()
    {
        segmentBodies = new List<Rigidbody2D>();

        // 自分の直下の子だけを対象にすることで、意図しない深い階層を拾わないようにします。
        for (int i = 0; i < transform.childCount; i++)
        {
            Transform child = transform.GetChild(i);
            Rigidbody2D rb = child.GetComponent<Rigidbody2D>();
            if (rb != null)
            {
                segmentBodies.Add(rb);
            }
        }

        // もし子に Rigidbody2D がなければ、自分自身をセグメントとみなす
        if (segmentBodies.Count == 0)
        {
            Rigidbody2D selfRb = GetComponent<Rigidbody2D>();
            if (selfRb != null)
            {
                segmentBodies.Add(selfRb);
            }
        }

        // 自動配置(オプション的な意味合い)
        ArrangeSegmentsLinearly();
    }

    /// <summary>
    /// セグメントを根本から先端にかけて一直線に並べます。
    /// 編集時の見た目を整える目的です。
    /// </summary>
    private void ArrangeSegmentsLinearly()
    {
        if (segmentBodies == null || segmentBodies.Count == 0) return;

        // 根本を基準に、ローカルX方向に並べる
        for (int i = 0; i < segmentBodies.Count; i++)
        {
            Rigidbody2D rb = segmentBodies[i];
            if (rb == null) continue;

            Vector3 localPos = new Vector3(i * segmentSpacing, 0f, 0f);
            rb.transform.localPosition = localPos;
        }
    }

    /// <summary>
    /// セグメント間をつなぐ Joint を初期化します。
    /// </summary>
    private void InitializeJoints()
    {
        _joints.Clear();

        if (segmentBodies == null || segmentBodies.Count < 2)
        {
            return;
        }

        for (int i = 0; i < segmentBodies.Count - 1; i++)
        {
            Rigidbody2D current = segmentBodies[i];
            Rigidbody2D next = segmentBodies[i + 1];

            if (current == null || next == null) continue;

            // すでに HingeJoint2D が付いている場合はそれを再利用
            HingeJoint2D joint = current.GetComponent<HingeJoint2D>();
            if (joint == null)
            {
                joint = current.gameObject.AddComponent<HingeJoint2D>();
            }

            joint.connectedBody = next;
            joint.autoConfigureConnectedAnchor = true;

            // バネ設定
            JointAngleLimits2D limits = joint.limits;
            limits.min = -jointAngleLimit;
            limits.max = jointAngleLimit;
            joint.limits = limits;
            joint.useLimits = useAngleLimits;

            JointMotor2D motor = joint.motor;
            motor.maxMotorTorque = 0f; // このコンポーネントではモーターは使わない
            joint.motor = motor;
            joint.useMotor = false;

            // ばねの設定(HingeJoint2D は frequency/dampingRatio を直接持たないので、
            // 角度制限と Rigidbody2D の慣性に依存した「擬似バネ」として扱います。
            // より高度な制御をしたい場合は、別途 SpringJoint2D などを組み合わせてください。)
            // ここでは一旦、角度制限と柔らかさパラメータのみ扱います。

            _joints.Add(joint);
        }

        // 柔らかさを反映(これは単純に質量と慣性のスケールとして扱います)
        ApplySoftnessToMass();
    }

    /// <summary>
    /// 柔らかさパラメータを各セグメントの質量に反映します。
    /// 柔らかいほど軽く、物理影響を受けやすくなります。
    /// </summary>
    private void ApplySoftnessToMass()
    {
        if (segmentBodies == null) return;

        foreach (var rb in segmentBodies)
        {
            if (rb == null) continue;

            // ベース質量を決めて、softness でスケーリング
            float baseMass = 1f;
            rb.mass = baseMass * Mathf.Clamp(softness, 0.1f, 5f);
        }
    }

    // ================================
    // ランタイム制御
    // ================================

    /// <summary>
    /// 各セグメントに重力スケールを適用します。
    /// </summary>
    private void ApplyGravityScale()
    {
        if (segmentBodies == null) return;

        foreach (var rb in segmentBodies)
        {
            if (rb == null) continue;
            rb.gravityScale = gravityScale;
        }
    }

    /// <summary>
    /// 根本および先端にターゲット追従の力を加えます。
    /// </summary>
    private void ApplyFollowForces()
    {
        if (segmentBodies == null || segmentBodies.Count == 0) return;

        // 根本追従
        if (rootTarget != null)
        {
            Rigidbody2D rootRb = segmentBodies[0];
            if (rootRb != null)
            {
                ApplyFollowForceToBody(rootRb, rootTarget.position);
            }
        }

        // 先端追従
        if (tipTarget != null)
        {
            Rigidbody2D tipRb = segmentBodies[segmentBodies.Count - 1];
            if (tipRb != null)
            {
                ApplyFollowForceToBody(tipRb, tipTarget.position);
            }
        }
    }

    /// <summary>
    /// 指定した Rigidbody2D を targetPosition に向かって引き寄せる力を加えます。
    /// </summary>
    private void ApplyFollowForceToBody(Rigidbody2D body, Vector3 targetPosition)
    {
        Vector2 currentPos = body.position;
        Vector2 targetPos2D = targetPosition;
        Vector2 toTarget = targetPos2D - currentPos;
        float distance = toTarget.magnitude;

        if (distance <= 0.0001f)
        {
            return;
        }

        // ターゲットに近い場合は力を弱める
        float distanceFactor = Mathf.InverseLerp(followStopDistance, 0f, distance);
        Vector2 dir = toTarget.normalized;

        // ForceMode2D.Force: 継続的な力として加える
        Vector2 force = dir * (followForce * distanceFactor);
        body.AddForce(force, ForceMode2D.Force);
    }

    // ================================
    // 公開API(他コンポーネントから呼べるメソッド)
    // ================================

    /// <summary>
    /// 触手の先端ターゲットを動的に変更します。
    /// 例: プレイヤーにロックオンしたり、別のオブジェクトに切り替えたり。
    /// </summary>
    public void SetTipTarget(Transform newTarget)
    {
        tipTarget = newTarget;
    }

    /// <summary>
    /// 触手の根本ターゲットを動的に変更します。
    /// 例: ボスの本体が移動する場合など。
    /// </summary>
    public void SetRootTarget(Transform newTarget)
    {
        rootTarget = newTarget;
    }

    /// <summary>
    /// セグメントリストを外部から再設定し、Joint を再構築します。
    /// セグメントの増減を行ったあとに呼び出してください。
    /// </summary>
    public void RebuildWithSegments(List<Rigidbody2D> newSegments)
    {
        segmentBodies = newSegments;
        InitializeJoints();
    }
}

使い方の手順

  1. 触手用プレハブを作る
    • 空のGameObjectを作成し、名前を TentacleRoot などにします。
    • このオブジェクトに TentacleLimb コンポーネントを追加します。
    • 2D物理で扱う前提なので、シーンは 2D モードにしておくと分かりやすいです。
  2. セグメント(関節)を並べる
    • TentacleRoot の子として、複数の GameObject を作成します(例: Segment0, Segment1, Segment2 …)。
    • 各子オブジェクトに以下のコンポーネントを追加します:
      • SpriteRenderer(触手の見た目用。お好みのスプライトを設定)
      • Rigidbody2D(Body Type は Dynamic のままでOK)
    • TentacleLimbReset() が呼ばれるか、インスペクターの segmentBodies に自動で登録されます。もし足りない場合は手動でドラッグ&ドロップして埋めましょう。
  3. ターゲットを用意する(例: プレイヤーや動く床)
    具体例として、触手の先端をプレイヤーに向け、根本をボス本体に固定するケースを考えます。
    • プレイヤー用の GameObject(Player)をシーンに配置しておきます。
    • ボス本体用の GameObject(BossBody)をシーンに配置しておきます。
    • TentacleRootTentacleLimb コンポーネントのインスペクターで:
      • Tip TargetPlayer をドラッグ&ドロップ
      • Root TargetBossBody をドラッグ&ドロップ
    • ゲームを再生すると、ボス本体から生えた触手が、物理的にうねりながらプレイヤーを追いかけるようになります。
  4. パラメータを調整して好みの触手に仕上げる
    • Joint Angle Limit を小さくすると、あまり曲がらない硬い触手になります。
    • Softness を下げるとふにゃふにゃ、上げると硬めの動きになります。
    • Follow Force を上げるとターゲットへの追従が速くなりますが、振動しやすくなるので、Joint Damping Ratio で減衰を強めると安定します。
    • Gravity Scale を 0 にすると、水中のようなふわふわした動き、1 にすると通常の重力を受けます。

    応用例としては、動く床に触手を生やすこともできます。
    動く床オブジェクトを Root Target に設定し、Tip Target をプレイヤーにすれば、「床から生えた触手がプレイヤーを追いかけるギミック」が簡単に作れます。

メリットと応用

TentacleLimb コンポーネントを使うと、

  • 「触手の物理挙動」を1つのコンポーネントに閉じ込められる
  • プレハブとして量産しやすい(ボス、トラップ、背景ギミックなど)
  • 見た目(スプライト)や攻撃判定(Collider2D + 別スクリプト)は、セグメント側に独立して追加できる

といったメリットがあります。特にレベルデザインの観点では、

  • 「触手プレハブ」をシーンにポンポン置くだけで、動きのある背景やトラップを量産できる
  • 根本や先端のターゲットを変えるだけで、さまざまなパターン(プレイヤー追尾、固定オブジェクトへの吸着など)が作れる
  • 1つのプレハブを複製してパラメータだけ変えることで、「長い触手」「短い触手」「重い触手」などを簡単にバリエーション化できる

といった形で、シーン構築がかなり楽になります。

さらに応用として、触手の先端を一時的に「硬く」することで、攻撃モーションや槍のような動きを作ることもできます。例えば、以下のような「硬化」メソッドを追加して、攻撃時だけ柔らかさを変えるといった改造が考えられます。


    /// <summary>
    /// 一時的に触手を硬く(または柔らかく)する簡易メソッドの例。
    /// 攻撃時に call して、一定時間後に元に戻すなどの用途を想定しています。
    /// </summary>
    public void SetTemporaryHardness(float multiplier, float duration)
    {
        // コルーチンで一定時間だけ softness を変更する例
        StartCoroutine(TemporaryHardnessRoutine(multiplier, duration));
    }

    private System.Collections.IEnumerator TemporaryHardnessRoutine(float multiplier, float duration)
    {
        float originalSoftness = softness;
        softness *= multiplier;
        ApplySoftnessToMass();

        yield return new WaitForSeconds(duration);

        softness = originalSoftness;
        ApplySoftnessToMass();
    }

このように、TentacleLimb 自体は「物理触手の制御」に責務を絞りつつ、外部から柔軟にパラメータをいじれるAPIを用意しておくと、他のコンポーネント(AI、攻撃制御、エフェクト制御など)と組み合わせやすくなります。小さな責務のコンポーネントを積み重ねて、扱いやすい触手モンスターやギミックを作っていきましょう。