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();
}
}
使い方の手順
-
触手用プレハブを作る
- 空のGameObjectを作成し、名前を
TentacleRootなどにします。 - このオブジェクトに
TentacleLimbコンポーネントを追加します。 - 2D物理で扱う前提なので、シーンは 2D モードにしておくと分かりやすいです。
- 空のGameObjectを作成し、名前を
-
セグメント(関節)を並べる
TentacleRootの子として、複数の GameObject を作成します(例:Segment0,Segment1,Segment2…)。- 各子オブジェクトに以下のコンポーネントを追加します:
SpriteRenderer(触手の見た目用。お好みのスプライトを設定)Rigidbody2D(Body Type はDynamicのままでOK)
TentacleLimbのReset()が呼ばれるか、インスペクターのsegmentBodiesに自動で登録されます。もし足りない場合は手動でドラッグ&ドロップして埋めましょう。
-
ターゲットを用意する(例: プレイヤーや動く床)
具体例として、触手の先端をプレイヤーに向け、根本をボス本体に固定するケースを考えます。- プレイヤー用の GameObject(
Player)をシーンに配置しておきます。 - ボス本体用の GameObject(
BossBody)をシーンに配置しておきます。 TentacleRootのTentacleLimbコンポーネントのインスペクターで:Tip TargetにPlayerをドラッグ&ドロップRoot TargetにBossBodyをドラッグ&ドロップ
- ゲームを再生すると、ボス本体から生えた触手が、物理的にうねりながらプレイヤーを追いかけるようになります。
- プレイヤー用の GameObject(
-
パラメータを調整して好みの触手に仕上げる
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、攻撃制御、エフェクト制御など)と組み合わせやすくなります。小さな責務のコンポーネントを積み重ねて、扱いやすい触手モンスターやギミックを作っていきましょう。
