Unityを触り始めた頃は、なんでもかんでも Update() に書いてしまいがちですよね。プレイヤーの入力、カメラの追尾、UIの更新、敵AI…全部ひとつのスクリプトに押し込んでしまうと、だんだん「どこを触ればいいか分からない巨大クラス」が出来上がります。

カメラ制御もその代表例で、「プレイヤーを追うカメラ」「ボス戦だけ複数ターゲットを映したいカメラ」などを全部一つのカメラスクリプトでやろうとすると、条件分岐だらけで地獄になりがちです。

そこで今回は、「複数のターゲットをまとめて追う」ことだけに責任を持つ小さなコンポーネントとして、「TargetGroup (複数追尾)」を作ってみましょう。カメラにこのコンポーネントをアタッチするだけで、複数のターゲットの中間点にカメラを移動し、全員が画面に入るようにズームを自動調整してくれます。

【Unity】複数キャラをまとめて追尾!「TargetGroup (複数追尾)」コンポーネント

以下は、Unity6 / C# 用のフルコードです。カメラにアタッチするだけで動きます。


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 複数ターゲットを追尾し、
/// ・ターゲット全員の中間点にカメラを移動
/// ・全員が画面内に収まるようにカメラ距離(ズーム)を自動調整
/// を行うコンポーネント。
///
/// カメラは以下のどちらかで動作します。
/// - Perspective カメラ: distance(カメラのZオフセット)を調整してズーム風に見せる
/// - Orthographic カメラ: orthographicSize を調整してズーム
/// </summary>
[RequireComponent(typeof(Camera))]
public class TargetGroup : MonoBehaviour
{
    // ====== ターゲット設定 ======

    [SerializeField]
    [Tooltip("追尾するターゲットの Transform を登録します。プレイヤーや敵など。")]
    private List<Transform> targets = new List<Transform>();

    [SerializeField]
    [Tooltip("ターゲットが一人もいないときに、カメラが向かうフォールバック位置(任意)。")]
    private Transform fallbackTarget;

    // ====== カメラ挙動設定 ======

    [Header("カメラ位置の設定")]

    [SerializeField]
    [Tooltip("カメラの高さ(Y方向オフセット)。2Dなら0でもOK。")]
    private float heightOffset = 10f;

    [SerializeField]
    [Tooltip("カメラの手前・奥方向(Zオフセット)。2Dなら -10 など。")]
    private float distanceOffset = -10f;

    [SerializeField]
    [Tooltip("ターゲットの中心点からのオフセット。2Dなら (0, 0, -10) など。")]
    private Vector3 pivotOffset = new Vector3(0f, 0f, -10f);

    [SerializeField]
    [Tooltip("カメラの移動スムーズさ。大きいほどゆっくり追従します。0だと即座に追従。")]
    private float moveSmoothTime = 0.2f;

    [Header("ズーム(距離 / サイズ)の設定")]

    [SerializeField]
    [Tooltip("全ターゲットを包むバウンディングボックスに対する余白率(0.1 なら10%余白)。")]
    private float paddingRatio = 0.2f;

    [SerializeField]
    [Tooltip("ズームのスムーズさ。大きいほどゆっくり変化します。0だと即座に変化。")]
    private float zoomSmoothTime = 0.2f;

    [SerializeField]
    [Tooltip("Orthographic カメラ時の最小サイズ。小さすぎるズームインを防ぐ。")]
    private float minOrthoSize = 5f;

    [SerializeField]
    [Tooltip("Orthographic カメラ時の最大サイズ。大きすぎるズームアウトを防ぐ。")]
    private float maxOrthoSize = 30f;

    [SerializeField]
    [Tooltip("Perspective カメラ時の距離の最小値(Zオフセットの絶対値)。")]
    private float minDistance = 5f;

    [SerializeField]
    [Tooltip("Perspective カメラ時の距離の最大値(Zオフセットの絶対値)。")]
    private float maxDistance = 40f;

    [Header("その他")]

    [SerializeField]
    [Tooltip("true のとき、Y方向の広がりもズーム計算に含めます。2D横スクロールなら false 推奨。")]
    private bool useVerticalSpread = true;

    [SerializeField]
    [Tooltip("true のとき、無効なターゲット( null )を自動でリストから削除します。")]
    private bool autoCleanupTargets = true;

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

    private Camera _camera;
    private Vector3 _currentVelocity;          // SmoothDamp 用
    private float _currentZoomVelocity;        // SmoothDamp 用(float版)

    private void Awake()
    {
        _camera = GetComponent<Camera>();
    }

    private void LateUpdate()
    {
        // カメラの追従は LateUpdate で行うと、他オブジェクトの移動が終わったあとに追従できる
        UpdateCameraPositionAndZoom();
    }

    /// <summary>
    /// ターゲットリストに Transform を追加します。
    /// スクリプトから動的に登録したい場合に使用します。
    /// </summary>
    public void AddTarget(Transform target)
    {
        if (target == null) return;
        if (!targets.Contains(target))
        {
            targets.Add(target);
        }
    }

    /// <summary>
    /// ターゲットリストから Transform を削除します。
    /// </summary>
    public void RemoveTarget(Transform target)
    {
        if (target == null) return;
        if (targets.Contains(target))
        {
            targets.Remove(target);
        }
    }

    /// <summary>
    /// 現在の有効なターゲット(null を除いたもの)を取得します。
    /// 内部的に Cleanup も行います。
    /// </summary>
    private List<Transform> GetValidTargets()
    {
        if (!autoCleanupTargets)
        {
            // Cleanup しない場合でも、null を除いたリストを返す
            List<Transform> result = new List<Transform>();
            foreach (var t in targets)
            {
                if (t != null) result.Add(t);
            }
            return result;
        }

        // autoCleanup が有効な場合、元リストからも null を削除
        for (int i = targets.Count - 1; i >= 0; i--)
        {
            if (targets[i] == null)
            {
                targets.RemoveAt(i);
            }
        }

        return targets;
    }

    /// <summary>
    /// カメラの位置とズームを更新するメイン処理。
    /// </summary>
    private void UpdateCameraPositionAndZoom()
    {
        List<Transform> validTargets = GetValidTargets();

        // ターゲットが一人もいない場合のフォールバック
        if (validTargets.Count == 0)
        {
            HandleNoTargets();
            return;
        }

        // ターゲットの中心点と広がり(バウンディングボックス)を計算
        Vector3 center = CalculateCenter(validTargets, out Bounds bounds);

        // カメラの理想位置を計算
        Vector3 desiredPosition = CalculateDesiredCameraPosition(center);

        // スムーズにカメラ位置を移動
        if (moveSmoothTime > 0f)
        {
            transform.position = Vector3.SmoothDamp(
                transform.position,
                desiredPosition,
                ref _currentVelocity,
                moveSmoothTime
            );
        }
        else
        {
            transform.position = desiredPosition;
        }

        // カメラの向きはターゲット中心を向かせる(必要に応じて変更可)
        transform.LookAt(center + Vector3.up * heightOffset * 0.1f);

        // ズーム(距離 or orthographicSize)を調整
        AdjustZoom(bounds);
    }

    /// <summary>
    /// ターゲットがいない場合の処理。
    /// fallbackTarget があればそこを向き、なければ何もしない。
    /// </summary>
    private void HandleNoTargets()
    {
        if (fallbackTarget == null) return;

        // フォールバックターゲットの位置へ移動
        Vector3 desiredPosition = fallbackTarget.position;
        desiredPosition.y += heightOffset;
        desiredPosition.z += distanceOffset;

        if (moveSmoothTime > 0f)
        {
            transform.position = Vector3.SmoothDamp(
                transform.position,
                desiredPosition,
                ref _currentVelocity,
                moveSmoothTime
            );
        }
        else
        {
            transform.position = desiredPosition;
        }

        transform.LookAt(fallbackTarget.position);
    }

    /// <summary>
    /// 全ターゲットを含む Bounds と、その中心点を計算します。
    /// </summary>
    private Vector3 CalculateCenter(List<Transform> validTargets, out Bounds bounds)
    {
        // 最初のターゲット位置で Bounds を初期化
        Vector3 firstPos = validTargets[0].position;
        bounds = new Bounds(firstPos, Vector3.zero);

        // 全ターゲットを Bounds に含める
        for (int i = 1; i < validTargets.Count; i++)
        {
            bounds.Encapsulate(validTargets[i].position);
        }

        // 中心点
        Vector3 center = bounds.center;
        return center;
    }

    /// <summary>
    /// ターゲット中心点から見た、理想的なカメラ位置を計算します。
    /// </summary>
    private Vector3 CalculateDesiredCameraPosition(Vector3 center)
    {
        // 基本はターゲット中心にオフセットを足すだけ
        Vector3 desired = center + pivotOffset;

        // 高さと距離オフセットを個別に足す
        desired.y += heightOffset;
        desired.z += distanceOffset;

        return desired;
    }

    /// <summary>
    /// カメラのズーム(OrthographicSize または 距離)を調整します。
    /// </summary>
    private void AdjustZoom(Bounds bounds)
    {
        if (_camera.orthographic)
        {
            AdjustOrthographicZoom(bounds);
        }
        else
        {
            AdjustPerspectiveZoom(bounds);
        }
    }

    /// <summary>
    /// Orthographic カメラ用のズーム調整。
    /// </summary>
    private void AdjustOrthographicZoom(Bounds bounds)
    {
        // 横幅と縦幅を取得
        float width = bounds.size.x;
        float height = useVerticalSpread ? bounds.size.y : 0f;

        // 画面アスペクト比に応じて必要サイズを計算
        float aspect = _camera.aspect;

        // 横方向に全員を収めるために必要な orthographicSize
        float requiredSizeByWidth = width * 0.5f / aspect;

        // 縦方向に全員を収めるために必要な orthographicSize
        float requiredSizeByHeight = useVerticalSpread ? height * 0.5f : 0f;

        // どちらか大きい方を採用
        float requiredSize = Mathf.Max(requiredSizeByWidth, requiredSizeByHeight, minOrthoSize);

        // 余白を追加
        requiredSize *= (1f + paddingRatio);

        // 最大値でクランプ
        requiredSize = Mathf.Clamp(requiredSize, minOrthoSize, maxOrthoSize);

        // スムーズにサイズ変更
        float targetSize = requiredSize;
        if (zoomSmoothTime > 0f)
        {
            _camera.orthographicSize = Mathf.SmoothDamp(
                _camera.orthographicSize,
                targetSize,
                ref _currentZoomVelocity,
                zoomSmoothTime
            );
        }
        else
        {
            _camera.orthographicSize = targetSize;
        }
    }

    /// <summary>
    /// Perspective カメラ用のズーム調整。
    /// 実際には Z 方向の距離(distanceOffset)を変更して、ズーム風に見せます。
    /// </summary>
    private void AdjustPerspectiveZoom(Bounds bounds)
    {
        // 横幅と縦幅を取得
        float width = bounds.size.x;
        float height = useVerticalSpread ? bounds.size.y : 0f;

        // カメラの視野角(垂直 FOV)をラジアンに
        float verticalFovRad = _camera.fieldOfView * Mathf.Deg2Rad;

        // アスペクト比から水平 FOV を計算
        float aspect = _camera.aspect;
        float horizontalFovRad = 2f * Mathf.Atan(Mathf.Tan(verticalFovRad / 2f) * aspect);

        // 横方向に全員を収めるために必要な距離
        float requiredDistanceByWidth = 0f;
        if (width > 0f)
        {
            requiredDistanceByWidth = (width * 0.5f) / Mathf.Tan(horizontalFovRad / 2f);
        }

        // 縦方向に全員を収めるために必要な距離
        float requiredDistanceByHeight = 0f;
        if (useVerticalSpread && height > 0f)
        {
            requiredDistanceByHeight = (height * 0.5f) / Mathf.Tan(verticalFovRad / 2f);
        }

        // どちらか大きい方を採用(0で割るのを避けるため Max を使う)
        float requiredDistance = Mathf.Max(requiredDistanceByWidth, requiredDistanceByHeight, minDistance);

        // 余白を追加
        requiredDistance *= (1f + paddingRatio);

        // クランプ
        requiredDistance = Mathf.Clamp(requiredDistance, minDistance, maxDistance);

        // 実際には distanceOffset(負数) を調整するので符号を反転
        float targetDistanceOffset = -requiredDistance;

        if (zoomSmoothTime > 0f)
        {
            float current = distanceOffset;
            distanceOffset = Mathf.SmoothDamp(
                current,
                targetDistanceOffset,
                ref _currentZoomVelocity,
                zoomSmoothTime
            );
        }
        else
        {
            distanceOffset = targetDistanceOffset;
        }

        // distanceOffset が変わったので、位置も再計算して反映
        // (位置は moveSmoothTime に従って追従する)
        // ※ここで直接 transform.position をいじると二重更新になるので、
        //    次フレームの LateUpdate で反映される形にしておく。
    }

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        // シーンビュー上で、ターゲットのバウンディングボックスと中心点を可視化
        List<Transform> validTargets = new List<Transform>();
        foreach (var t in targets)
        {
            if (t != null) validTargets.Add(t);
        }

        if (validTargets.Count == 0) return;

        CalculateCenter(validTargets, out Bounds bounds);

        Gizmos.color = Color.yellow;
        Gizmos.DrawWireCube(bounds.center, bounds.size);

        Gizmos.color = Color.cyan;
        Gizmos.DrawSphere(bounds.center, 0.2f);
    }
#endif
}

使い方の手順

ここでは、2D横スクロールで「プレイヤー2人 coop」を両方映すカメラを例に説明します。3Dでも手順はほぼ同じです。

  1. シーンにカメラを用意する
    • Hierarchy で Main Camera を選択(無ければ作成)。
    • 2Dゲームなら Projection = Orthographic を推奨。
  2. TargetGroup コンポーネントを追加する
    • Main Camera を選択し、Inspector の「Add Component」ボタンを押す。
    • TargetGroup と入力して、上記スクリプトをアタッチ。
  3. ターゲットを登録する
    • Hierarchy 上のプレイヤー1、プレイヤー2のオブジェクトを確認。
    • Main Camera の Inspector で、Targets リストに要素数を 2 に増やす。
    • それぞれに Player1Player2 の Transform をドラッグ&ドロップ。
    • 2D横スクロールなら、useVerticalSpreadfalse にすると、縦方向の距離は無視して横方向だけでズームします。
  4. パラメータを調整して動きを確認する
    • heightOffset:2Dなら 0、3Dなら 5〜15 あたりから調整。
    • pivotOffset:2Dなら (0, 0, -10) が定番。
    • paddingRatio:0.15〜0.25 くらいにすると、キャラの周りに少し余白ができて見やすくなります。
    • moveSmoothTime, zoomSmoothTime:0.15〜0.3 くらいで、ヌルっとした追従になります。

    ゲームを再生して、プレイヤー2人を左右に離してみましょう。カメラが自動で引いて、2人が画面内に収まるはずです。逆に近づけると、カメラが寄っていきます。

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

  • ボス戦で「プレイヤー + ボス」を同時に映すカメラ
    ボス出現時に TargetGroup.AddTarget(bossTransform) を呼び、撃破時に RemoveTarget することで、戦闘中だけボスも一緒に映すことができます。
  • マルチプレイで「全プレイヤー」を常に映すカメラ
    ロビーに入ってきたプレイヤーを順次 AddTarget し、抜けたら RemoveTarget。プレイヤー数に応じて自動でズームが変わります。
  • 動く足場 + プレイヤー
    「プレイヤーが乗ると危険な足場」などもターゲットに入れておくと、足場の動きに合わせてカメラが少し先を見せてくれるような演出もできます。

メリットと応用

この TargetGroup コンポーネントを使うメリットは、「カメラの責務をシンプルに分離できる」ことです。

  • プレハブ管理が楽になる
    • 「複数ターゲットを追うカメラ」という振る舞いを 1 コンポーネントに閉じ込められるので、カメラのプレハブを量産しやすくなります。
    • シーンごとに「このシーンはプレイヤー2人 + ボス」「このシーンはプレイヤー1人だけ」といった違いがあっても、ターゲットのリストを変えるだけで対応できます。
  • レベルデザインがやりやすい
    • レベルデザイナーは、単に「このオブジェクトをカメラで追ってほしい」と思ったら、その Transform を Targets リストに追加するだけでOKです。
    • 「ボスエリアに入ったらボスも追う」「ギミック発動中はギミックも映す」など、演出の追加が Inspector 上の設定だけで完結しやすくなります。
  • コンポーネント指向で拡張しやすい
    • 「複数ターゲットを追う」という責務しか持たないので、他のカメラ演出(カメラシェイク、ポストプロセス切り替えなど)は別コンポーネントに切り出せます。
    • 結果的に、1ファイルが巨大化しにくく、保守しやすい構造になります。

応用としては、「特定のターゲットだけ重みを大きくして、中心点を寄せる」といった拡張や、「一定時間だけターゲットを追加する演出」などが考えられます。

例えば、「一時的にイベント用ターゲットを追加し、数秒後に自動で外す」ヘルパー関数を追加する改造案はこんな感じです。


    /// <summary>
    /// 一時的にターゲットを追加し、指定秒数後に自動で削除するサンプル。
    /// コルーチンを使うので、StartCoroutine で呼び出してください。
    /// </summary>
    public System.Collections.IEnumerator AddTemporaryTarget(Transform tempTarget, float duration)
    {
        if (tempTarget == null) yield break;

        AddTarget(tempTarget);

        float timer = 0f;
        while (timer < duration)
        {
            if (tempTarget == null) break; // 途中で破棄されたら終了
            timer += Time.deltaTime;
            yield return null;
        }

        RemoveTarget(tempTarget);
    }

イベントカットシーンで「爆発地点」や「重要アイテム」を一瞬だけカメラに映したいときなどに便利ですね。こうした小さな責務のコンポーネントを積み重ねていくと、ゲーム全体の設計もどんどんシンプルになっていきます。