Unityを始めたばかりの頃は、プレイヤーの移動もカメラ追従も当たり判定も、つい Update() の中に全部書いてしまいがちですよね。最初はそれでも動きますが、機能が増えるにつれて「どこを直せばいいのか分からない」「ちょっと直すと別のバグが出る」という沼にハマりやすくなります。

そこでおすすめなのが、「役割ごとに小さなコンポーネントに分ける」やり方です。今回紹介する OrbitalShield(ビットシールド) コンポーネントは、

  • 親オブジェクトの周囲をぐるぐる回転するオプションパーツ
  • 触れた敵弾(Bullet)を消すシールド

という機能だけに責務を絞った、シンプルなコンポーネントです。プレイヤー本体のスクリプトを太らせず、「回転するシールドの挙動」だけを独立したコンポーネントとして実装していきましょう。

【Unity】親の周りをぐるぐる防御!「OrbitalShield」コンポーネント

フルコード:OrbitalShield.cs


using UnityEngine;

/// <summary>
/// 親オブジェクトの周囲を公転し、敵弾と接触したら破壊するビットシールド。
/// ・親の周囲を一定半径で回り続ける
/// ・親が動いても常に追従する
/// ・敵弾(タグ指定)と接触したら、その弾を消す
/// </summary>
[RequireComponent(typeof(Collider))]
public class OrbitalShield : MonoBehaviour
{
    // ====== 公転設定 ======

    [Header("公転設定")]
    [Tooltip("公転の中心となる Transform。通常はプレイヤーや本体オブジェクトを指定します。")]
    [SerializeField] private Transform orbitCenter;

    [Tooltip("公転半径。中心からどれくらい離れた位置を回るか。")]
    [SerializeField] private float orbitRadius = 2.0f;

    [Tooltip("公転の角速度(度/秒)。正の値で反時計回り、負の値で時計回り。")]
    [SerializeField] private float angularSpeed = 90.0f;

    [Tooltip("公転する面の法線ベクトル。通常は (0,1,0) で水平面上を回転。")]
    [SerializeField] private Vector3 orbitAxis = Vector3.up;

    [Header("当たり判定設定")]
    [Tooltip("敵弾として扱うタグ名。このタグを持つオブジェクトと接触すると破壊します。")]
    [SerializeField] private string enemyBulletTag = "EnemyBullet";

    [Tooltip("敵弾を消したときに生成するエフェクト。不要なら null のままでOK。")]
    [SerializeField] private GameObject hitEffectPrefab;

    [Tooltip("敵弾を消したときに再生するサウンド。不要なら null のままでOK。")]
    [SerializeField] private AudioClip hitSeClip;

    [Header("オプション")]
    [Tooltip("ゲーム開始時に自動で orbitCenter を親 Transform に設定するか。")]
    [SerializeField] private bool useParentAsCenterOnStart = true;

    [Tooltip("ゲーム開始時に現在位置から中心への方向を初期位相として使うか。")]
    [SerializeField] private bool useCurrentOffsetAsInitialAngle = true;

    // 内部用:現在の角度(度)
    private float _currentAngle;

    // 内部用:オーディオ再生
    private AudioSource _audioSource;

    private void Awake()
    {
        // AudioSource がなければ自動追加(サウンド再生用)
        if (hitSeClip != null)
        {
            _audioSource = gameObject.GetComponent<AudioSource>();
            if (_audioSource == null)
            {
                _audioSource = gameObject.AddComponent<AudioSource>();
                _audioSource.playOnAwake = false;
            }
        }

        // Collider をトリガーにしておく(物理的な押し戻しは不要なため)
        Collider col = GetComponent<Collider>();
        col.isTrigger = true;
    }

    private void Start()
    {
        // 親を自動で公転中心にするオプション
        if (useParentAsCenterOnStart && orbitCenter == null && transform.parent != null)
        {
            orbitCenter = transform.parent;
        }

        // 公転中心が未設定の場合、警告を出してこのコンポーネントを無効化
        if (orbitCenter == null)
        {
            Debug.LogWarning(
                $"[OrbitalShield] orbitCenter が設定されていません。ゲームオブジェクト: {name}",
                this);
            enabled = false;
            return;
        }

        // 公転軸がゼロベクトルの場合は up に補正
        if (orbitAxis == Vector3.zero)
        {
            orbitAxis = Vector3.up;
        }

        // 初期角度の計算
        if (useCurrentOffsetAsInitialAngle)
        {
            // 現在位置から中心へのオフセットを元に、初期角度を求める
            Vector3 offset = transform.position - orbitCenter.position;

            // orbitAxis の平面に射影して、2D的な角度を求める
            // 1. orbitAxis を正規化
            Vector3 axis = orbitAxis.normalized;

            // 2. 任意の基底ベクトルを作る(軸と直交するベクトル)
            Vector3 basisX;
            if (Vector3.Dot(axis, Vector3.up) > 0.99f)
            {
                // 軸がほぼ上向きなら、別の軸を使う
                basisX = Vector3.right;
            }
            else
            {
                basisX = Vector3.Cross(axis, Vector3.up).normalized;
            }

            // 3. basisX と axis から、直交する basisY を作る
            Vector3 basisY = Vector3.Cross(axis, basisX).normalized;

            // 4. offset をこの2軸上に投影して角度を算出
            float x = Vector3.Dot(offset, basisX);
            float y = Vector3.Dot(offset, basisY);
            _currentAngle = Mathf.Atan2(y, x) * Mathf.Rad2Deg;

            // 半径も現在の距離を使う
            orbitRadius = offset.magnitude;
        }
        else
        {
            // 初期角度は 0 度。位置を強制的に再配置する
            _currentAngle = 0f;
            UpdateShieldPosition(0f); // deltaTime=0 で一度だけ座標更新
        }
    }

    private void Update()
    {
        if (orbitCenter == null) return;

        // 経過時間に応じて角度を進める
        UpdateShieldPosition(Time.deltaTime);
    }

    /// <summary>
    /// 角度を進めて、シールドの位置を更新する。
    /// </summary>
    private void UpdateShieldPosition(float deltaTime)
    {
        // 角度を更新(度単位)
        _currentAngle += angularSpeed * deltaTime;

        // 角度を 0-360 に正規化(オーバーフロー防止)
        if (_currentAngle >= 360f || _currentAngle <= -360f)
        {
            _currentAngle %= 360f;
        }

        // orbitAxis を軸として角度分回転させるクォータニオン
        Quaternion rotation = Quaternion.AngleAxis(_currentAngle, orbitAxis.normalized);

        // 基本となる位置(中心から orbitRadius 分だけ離れた位置)
        // ここでは orbitAxis と直交する任意の方向を基準にする
        Vector3 baseDirection = GetBaseDirectionOnOrbitPlane();
        Vector3 offset = rotation * baseDirection * orbitRadius;

        transform.position = orbitCenter.position + offset;

        // シールドの向きは、中心の方向を向かせる例(好みで変更可)
        // 中心に向かって常に「内向き」を向く
        transform.rotation = Quaternion.LookRotation(-offset.normalized, orbitAxis.normalized);
    }

    /// <summary>
    /// 公転平面上の基準方向ベクトルを取得する。
    /// orbitAxis と直交する任意の正規化ベクトルを返す。
    /// </summary>
    private Vector3 GetBaseDirectionOnOrbitPlane()
    {
        Vector3 axis = orbitAxis.normalized;

        // 軸とあまり平行でないベクトルを選ぶ
        Vector3 temp = Mathf.Abs(Vector3.Dot(axis, Vector3.up)) > 0.9f
            ? Vector3.right
            : Vector3.up;

        Vector3 baseDir = Vector3.Cross(axis, temp).normalized;
        return baseDir;
    }

    // ====== 当たり判定(敵弾を消す) ======

    private void OnTriggerEnter(Collider other)
    {
        // タグが一致しない場合は何もしない
        if (!other.CompareTag(enemyBulletTag)) return;

        // 敵弾を消す
        Destroy(other.gameObject);

        // ヒットエフェクトを出す
        if (hitEffectPrefab != null)
        {
            Instantiate(hitEffectPrefab, other.transform.position, Quaternion.identity);
        }

        // ヒットSEを鳴らす
        if (hitSeClip != null && _audioSource != null)
        {
            _audioSource.PlayOneShot(hitSeClip);
        }
    }
}

使い方の手順

ここでは「プレイヤーの周りを回るビットシールド」として使う例で説明します。

  1. プレイヤーのセットアップ
    すでにプレイヤーオブジェクトがある前提で進めます(例: Player)。
    プレイヤーには特にこのコンポーネントへの依存はありませんが、Player を公転の中心にします。
  2. ビットシールド用のプレハブを作成
    • 空の GameObject を作成し、名前を OrbitalShield に変更
    • 見た目用に Sphere や小さな Mesh、Sprite などを子オブジェクトとして配置
    • Collider(SphereCollider など)を追加し、Is Trigger にチェック
    • 上記の OrbitalShield.cs を追加
    • 必要なら AudioSource は自動追加されるので、特に手動で付けなくてもOK
    • 作成した GameObject を Project ウィンドウにドラッグしてプレハブ化
  3. 敵弾(Bullet)のタグ設定
    • 敵弾用プレハブ(例: EnemyBullet)を選択
    • Inspector の「Tag」を EnemyBullet に設定(存在しなければ Add Tag で追加)
    • このタグ名を OrbitalShieldenemyBulletTag と一致させる
    • 敵弾は ColliderIs Trigger はオフでOK)と Rigidbody を持っていると扱いやすいです
  4. シーンに配置して動作確認
    • シーン上の Player オブジェクトの子として、OrbitalShield プレハブを配置
    • OrbitalShield コンポーネントの設定を確認:
      • Use Parent As Center On Start:チェック(親を自動で中心に)
      • Use Current Offset As Initial Angle:チェック(今の位置を初期位相に)
      • Orbit Radius:プレイヤーからどれくらい離すか(例: 2.0)
      • Angular Speed:回転速度(例: 120 で素早く回転)
      • Orbit Axis:通常は (0,1,0) で水平面上を回転
      • Enemy Bullet TagEnemyBullet
      • Hit Effect Prefab:パーティクルなどがあればアサイン
      • Hit Se Clip:ヒット音があればアサイン
    • ゲームを再生し、敵弾がプレイヤーに向かって飛んでくるようにしてテスト
    • ビットシールドに触れた弾が消え、エフェクトやSEが再生されれば成功です

同じ要領で、

  • ボスの周囲を回る破壊可能なオーブ
  • 動く床の周りを回るギミック(触れるとダメージ or 足場になる)
  • 拠点の周囲を回る防衛ドローン

などにも簡単に流用できます。

メリットと応用

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

  • プレイヤーのスクリプトが肥大化しない
    「移動」「攻撃」「シールド」「UI更新」などを1つのクラスに押し込まず、
    シールドの挙動は OrbitalShield 単体に閉じ込められます。
  • プレハブとして量産しやすい
    回転速度や半径、当たり判定の挙動をプレハブ側でパラメータ調整できるので、
    FastShieldWideShieldSlowButStrongShield などを量産しやすくなります。
  • レベルデザインが楽になる
    シーン上に「ボスの周りを回るオーブ」をぽんぽん配置するだけで、
    それっぽいギミックが簡単に作れます。中心となるオブジェクトを差し替えるだけで挙動を使い回せます。
  • テストしやすい
    シールドだけを単体でシーンに置いて、中心をダミーオブジェクトにすれば、
    プレイヤーのロジックに依存せずに挙動テストができます。

さらに、簡単な改造で「耐久値付きシールド」や「時間で消えるシールド」にもできます。例えば、


/// <summary>
/// 一定数の敵弾を受けたらシールド自体が消えるようにする例。
/// OrbitalShield に追記してもいいし、別コンポーネントとして分けてもOK。
/// </summary>
[SerializeField] private int maxHitCount = 5;
private int _currentHitCount = 0;

private void OnTriggerEnter(Collider other)
{
    if (!other.CompareTag(enemyBulletTag)) return;

    Destroy(other.gameObject);

    // ヒット演出(元の処理)
    if (hitEffectPrefab != null)
    {
        Instantiate(hitEffectPrefab, other.transform.position, Quaternion.identity);
    }
    if (hitSeClip != null && _audioSource != null)
    {
        _audioSource.PlayOneShot(hitSeClip);
    }

    // 耐久値を減らす
    _currentHitCount++;
    if (_currentHitCount >= maxHitCount)
    {
        // シールドを破壊
        Destroy(gameObject);
    }
}

このように、1つのコンポーネントに「公転」「敵弾消去」という明確な責務だけを持たせておけば、
耐久値・リチャージ・色変更などの追加要素は、別コンポーネントとして後からいくらでも足せます。
Godクラスを避けて、小さなコンポーネントを組み合わせる設計を意識していきましょう。