Unityを触り始めた頃にありがちなのが、1つのプレイヤースクリプトの Update に「移動」「アニメーション」「入力」「カメラ制御」「当たり判定チェック」…と、全部を詰め込んでしまうパターンですね。
動き始めたときは良いのですが、あとから「画面端でループさせたい」「敵にも同じ処理を使い回したい」となった瞬間、コードをコピペしたり、巨大な if 文の塊を編集したりと、メンテナンスが一気に地獄化します。

そこでこの記事では、「画面端ループ」というよくあるゲーム仕様を、きれいに独立したコンポーネントとして切り出した 「ScreenWrapper」コンポーネント を紹介します。
プレイヤーでも敵でも、動く床でも、「画面外に出たら反対側から出てきてほしい」 というオブジェクトにポン付けするだけで使えるようにしていきましょう。

【Unity】シューティングやアクションに必須の画面端ループ!「ScreenWrapper」コンポーネント

ここでは、カメラに映る画面領域を元にして、オブジェクトが画面外へ完全に抜けたら反対側の端にワープさせる ScreenWrapper コンポーネントを実装します。

  • 2D/3D どちらでも使える(Zはそのまま)
  • カメラサイズに自動追従(解像度変更やカメラズームにも対応)
  • スプライトやモデルの大きさを考慮して「完全に画面外に消えてから」ワープ
  • 親オブジェクトの Transform をそのまま使える(子オブジェクトに付けてもOK)

フルコード:ScreenWrapper.cs


using UnityEngine;

/// <summary>
/// 画面端ループ用コンポーネント。
/// 対象オブジェクトがカメラのビュー外に完全に出たら、
/// 反対側の端に座標をテレポートさせる。
/// 2D/3D 両対応(Z座標はそのまま)
/// </summary>
[DisallowMultipleComponent]
public class ScreenWrapper : MonoBehaviour
{
    // ラップ対象のカメラ。未指定なら mainCamera を自動取得
    [SerializeField] private Camera targetCamera;

    // オブジェクトの「サイズ」をどのように判定するか
    // SpriteRenderer, Renderer, Collider などから自動取得できるようにしておく
    [Header("オブジェクトの半径(自動推定も可)")]
    [Tooltip("画面外に出たと判定する『余白』。0 なら中心基準。")]
    [SerializeField] private float radiusMargin = 0.0f;

    [Tooltip("起動時に SpriteRenderer / Renderer / Collider から半径を自動推定する")]
    [SerializeField] private bool autoDetectRadius = true;

    // 実際に使用する判定用半径
    private float _radius;

    // カメラの境界をワールド座標でキャッシュ
    private float _left;
    private float _right;
    private float _top;
    private float _bottom;

    // 前フレームの画面サイズを記録して、解像度変更やリサイズに対応
    private int _lastScreenWidth;
    private int _lastScreenHeight;

    private void Awake()
    {
        // カメラが未指定なら mainCamera を使う
        if (targetCamera == null)
        {
            targetCamera = Camera.main;
        }

        if (targetCamera == null)
        {
            Debug.LogError("[ScreenWrapper] Camera が見つかりません。targetCamera をインスペクターで指定してください。", this);
            enabled = false;
            return;
        }

        // 半径の決定
        InitializeRadius();

        // カメラ境界の初期計算
        CacheCameraBounds();

        _lastScreenWidth = Screen.width;
        _lastScreenHeight = Screen.height;
    }

    private void Update()
    {
        // 画面サイズが変化していたら、カメラ境界を再計算
        if (_lastScreenWidth != Screen.width || _lastScreenHeight != Screen.height)
        {
            CacheCameraBounds();
            _lastScreenWidth = Screen.width;
            _lastScreenHeight = Screen.height;
        }

        WrapIfNeeded();
    }

    /// <summary>
    /// オブジェクトの「半径」を決める。
    /// SpriteRenderer, Renderer, Collider などから自動推定する。
    /// </summary>
    private void InitializeRadius()
    {
        // 明示的に radiusMargin が設定されていて、かつ自動推定しないならそれを利用
        if (!autoDetectRadius)
        {
            _radius = Mathf.Max(0f, radiusMargin);
            return;
        }

        float detected = 0f;

        // 1. SpriteRenderer があればそこから推定(2D用)
        var spriteRenderer = GetComponentInChildren<SpriteRenderer>();
        if (spriteRenderer != null)
        {
            // bounds.extents はオブジェクトの半分のサイズ(XYZ)
            detected = spriteRenderer.bounds.extents.magnitude;
        }
        else
        {
            // 2. 通常の Renderer(3D MeshRenderer など)
            var renderer = GetComponentInChildren<Renderer>();
            if (renderer != null)
            {
                detected = renderer.bounds.extents.magnitude;
            }
            else
            {
                // 3. Collider から推定(コリジョンベース)
                var col = GetComponentInChildren<Collider>();
                if (col != null)
                {
                    detected = col.bounds.extents.magnitude;
                }
                else
                {
                    var col2d = GetComponentInChildren<Collider2D>();
                    if (col2d != null)
                    {
                        detected = col2d.bounds.extents.magnitude;
                    }
                }
            }
        }

        // 何も見つからなかった場合は 0 とする
        // radiusMargin があればそれを優先的に足す
        _radius = Mathf.Max(0f, detected + radiusMargin);
    }

    /// <summary>
    /// カメラの現在のビュー範囲をワールド座標でキャッシュする。
    /// </summary>
    private void CacheCameraBounds()
    {
        if (targetCamera == null) return;

        // nearClipPlane だとカメラに近すぎるので、Zは対象オブジェクトの位置を使う。
        // ScreenToWorldPoint の引数は (x, y, z) で、z はカメラからの距離。
        float distance = Mathf.Abs(transform.position.z - targetCamera.transform.position.z);

        // 画面左下(0,0)、右上(Screen.width, Screen.height) をワールド座標に変換
        Vector3 bottomLeft = targetCamera.ScreenToWorldPoint(new Vector3(0, 0, distance));
        Vector3 topRight = targetCamera.ScreenToWorldPoint(new Vector3(Screen.width, Screen.height, distance));

        _left = bottomLeft.x;
        _right = topRight.x;
        _bottom = bottomLeft.y;
        _top = topRight.y;
    }

    /// <summary>
    /// 画面外に出ていれば反対側にワープさせる。
    /// </summary>
    private void WrapIfNeeded()
    {
        Vector3 pos = transform.position;
        bool wrapped = false;

        // X方向のチェック
        float leftLimit = _left - _radius;
        float rightLimit = _right + _radius;

        if (pos.x < leftLimit)
        {
            // 右端の外側に移動
            pos.x = rightLimit;
            wrapped = true;
        }
        else if (pos.x > rightLimit)
        {
            // 左端の外側に移動
            pos.x = leftLimit;
            wrapped = true;
        }

        // Y方向のチェック
        float bottomLimit = _bottom - _radius;
        float topLimit = _top + _radius;

        if (pos.y < bottomLimit)
        {
            pos.y = topLimit;
            wrapped = true;
        }
        else if (pos.y > topLimit)
        {
            pos.y = bottomLimit;
            wrapped = true;
        }

        if (wrapped)
        {
            transform.position = pos;
        }
    }

#if UNITY_EDITOR
    // Sceneビュー上でカメラ境界とオブジェクト半径を可視化するとデバッグが楽
    private void OnDrawGizmosSelected()
    {
        if (targetCamera == null) return;

        CacheCameraBounds();

        // カメラ境界
        Gizmos.color = Color.cyan;
        Vector3 center = new Vector3((_left + _right) * 0.5f, (_top + _bottom) * 0.5f, transform.position.z);
        Vector3 size = new Vector3(_right - _left, _top - _bottom, 0.1f);
        Gizmos.DrawWireCube(center, size);

        // オブジェクト半径(おおよその円)
        Gizmos.color = Color.yellow;
        const int segments = 32;
        float r = (_radius > 0f) ? _radius : 0.5f;
        Vector3 prev = transform.position + new Vector3(r, 0f, 0f);
        for (int i = 1; i <= segments; i++)
        {
            float angle = (float)i / segments * Mathf.PI * 2f;
            Vector3 next = transform.position + new Vector3(Mathf.Cos(angle) * r, Mathf.Sin(angle) * r, 0f);
            Gizmos.DrawLine(prev, next);
            prev = next;
        }
    }
#endif
}

使い方の手順

ここからは、実際にどうやって使うかを具体的な例と一緒に見ていきましょう。

手順①:スクリプトを用意する

  1. Unity プロジェクトの Assets フォルダ内に Scripts フォルダを作成します。
  2. ScreenWrapper.cs という名前で C# スクリプトを作成し、上記のコードをそのままコピペして保存します。

手順②:メインカメラを確認する

  • シーン内に Main Camera が存在していることを確認します。
  • Main CameraTagMainCamera になっていれば、targetCamera を空のままでも自動で拾われます
  • もし別のカメラを使いたい場合は、ScreenWrappertargetCamera にそのカメラをドラッグ&ドロップしてください。

手順③:プレイヤー・敵・動く床にアタッチする

例えば、以下のようなオブジェクトに付けると便利です。

  • プレイヤーの宇宙船(2Dシューティング)
    PlayerShip という GameObject に、移動用スクリプトと一緒に ScreenWrapper をアタッチ。
    – 画面端から出ていっても、反対側からニュッと出てくる「Asteroids」風の挙動が簡単に実現できます。
  • 敵キャラクター
    – 画面外をグルグル周回する敵にアタッチしておくと、レベルデザインが楽になります。
    – 「画面外に消えたら Destroy」ではなく、「反対側から再登場」させるパターンに向いています。
  • 動く床(2Dアクション)
    – 左右に動き続ける足場に ScreenWrapper を付けると、端から端へループするようなギミックが簡単に作れます。

アタッチ手順はどれも共通です。

  1. 対象の GameObject を選択します(例:PlayerShip)。
  2. Inspector の「Add Component」ボタンから ScreenWrapper を追加します。
  3. 特別な設定をしなくても、自動でオブジェクトのサイズを推定してくれるので、そのまま再生して動作を確認できます。
  4. もし「もう少し画面から見切れてからワープしてほしい」と感じたら、radiusMargin を少し大きめの値(例:0.5〜1.0)に調整してみてください。

手順④:簡単な移動スクリプトと組み合わせてテストする

シンプルなテスト用プレイヤー移動スクリプトと一緒に動かしてみましょう。


using UnityEngine;

/// <summary>
/// 矢印キー / WASD で動かすだけのシンプルな移動コンポーネント。
/// ScreenWrapper と組み合わせて動作確認用に使う。
/// </summary>
[RequireComponent(typeof(ScreenWrapper))]
public class SimpleMover : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;

    private void Update()
    {
        float h = Input.GetAxisRaw("Horizontal");
        float v = Input.GetAxisRaw("Vertical");

        Vector3 dir = new Vector3(h, v, 0f).normalized;
        transform.position += dir * moveSpeed * Time.deltaTime;
    }
}
  1. 空の GameObject を作成し、適当なスプライト(四角や丸)を付けます。
  2. ScreenWrapperSimpleMover をアタッチします。
  3. 再生して矢印キー/WASD で動かすと、画面端から出たときに反対側へワープするのが確認できるはずです。

メリットと応用

ScreenWrapper をコンポーネントとして独立させる一番のメリットは、「画面端ループ」という仕様をプレイヤーや敵のロジックから完全に切り離せることです。

  • プレハブの再利用性が上がる
    – 「ラップするプレイヤー」「ラップしないプレイヤー」を、コンポーネントの有無だけで切り替えられます。
    – 敵プレハブにも同じ ScreenWrapper を付けるだけで挙動を共有できます。
  • レベルデザインがシンプルになる
    – 「このステージでは画面端ループ」「このステージではループなし」という仕様も、プレハブ差し替えや Prefab Variant で簡単に管理できます。
    – ステージごとにスクリプトを書き換える必要がなくなります。
  • 責務が明確でテストしやすい
    ScreenWrapper は「画面端ループの判定とテレポート」だけを担当します。
    – 移動やAIは別コンポーネントに任せることで、バグが出たときにどこを疑えばいいかが分かりやすくなります。

このように、小さな責務ごとにコンポーネントを分割しておくと、Godクラス化を防ぎつつ、後からの仕様変更にも強い構成になります。

改造案:ワープ時にエフェクトを出す

応用として、「ワープした瞬間にパーティクルを出したい」「SEを鳴らしたい」といった要望もよくあります。
その場合は、ScreenWrapper にイベント用のフックメソッドを追加し、別コンポーネントから購読する形にするとスッキリします。

例えば、こんな感じのシンプルな改造をイメージできます。


private void OnWrapped()
{
    // ここにワープ時の処理を追加する
    // 例:パーティクル再生、SE再生、ログ出力など
    // GetComponent<AudioSource>()?.Play();
    // warpParticle.Play();
    Debug.Log($"[ScreenWrapper] Wrapped: {name}");
}

そして WrapIfNeeded() の最後で OnWrapped() を呼ぶようにすれば、
ラップのロジック自体はそのままに、演出だけを柔軟に追加できます。

このように、まずは「画面端ループ」という機能をコンポーネントとしてきれいに切り出しておき、
その上に「演出」「スコア加算」「イベント通知」などを小さなコンポーネントとして積み上げていくと、拡張しやすい設計になりますね。