Unityを触り始めた頃は、ついなんでもかんでも Update() の中に書きがちですよね。プレイヤーの移動、カメラの追従、UIの更新、エネミーAI、SE再生……全部ひとつのスクリプトに押し込んでしまうと、

  • どの処理が何をしているのか追いにくい
  • 後から仕様変更やデバッグがしづらい
  • 別シーンや別プロジェクトで再利用しづらい

といった問題が一気に噴き出します。

この記事では、そういった「Godクラス」化を避けるために、目的地の方向を画面端の矢印で示すだけに責務を絞ったコンポーネント 「ObjectiveMarker」 を作っていきます。
プレイヤーの移動やステージ管理とは完全に分離し、「目的地の方向をUIで示す」専用の小さなコンポーネントとして設計していきましょう。

【Unity】画面外の目的地を見失わない!「ObjectiveMarker」コンポーネント

ここでは、

  • 目的地(Transform)
  • プレイヤーを映しているカメラ
  • 画面端に表示する矢印アイコン(UI)

を紐づけて、目的地が画面外にあるときだけ、その方向を指し示す矢印を画面端に表示するコンポーネントを実装します。

フルコード:ObjectiveMarker.cs


using UnityEngine;
using UnityEngine.UI;

namespace Samples.UI
{
    /// <summary>
    /// 画面外にある目的地の方向を、画面端の矢印アイコンで示すコンポーネント。
    /// - ワールド上の目的地(Transform)を指定
    /// - カメラから見たスクリーン座標を計算
    /// - 画面外なら画面端に矢印を表示、画面内なら非表示
    /// 
    /// Canvas は Screen Space - Overlay / Camera のどちらでも動作します。
    /// </summary>
    public class ObjectiveMarker : MonoBehaviour
    {
        // ====== 参照系 ======

        [Header("必須参照")]
        [SerializeField]
        private Camera targetCamera;
        // 目的地(例: ゴール地点のTransform)
        [SerializeField]
        private Transform objectiveTarget;
        // 画面端に表示する矢印アイコン(RectTransform)
        [SerializeField]
        private RectTransform arrowRectTransform;

        // ====== 表示調整用パラメータ ======

        [Header("画面端へのマージン(ピクセル)")]
        [SerializeField]
        private float screenBorderPadding = 50f;

        [Header("目的地が画面内にあるとき非表示にするか")]
        [SerializeField]
        private bool hideArrowWhenOnScreen = true;

        [Header("目的地との距離に応じて透明度を変えるか")]
        [SerializeField]
        private bool useDistanceFade = false;

        [SerializeField]
        [Tooltip("useDistanceFade が true のときのみ使用。これ以上遠いと最小アルファになる距離。")]
        private float maxFadeDistance = 50f;

        [SerializeField]
        [Tooltip("useDistanceFade が true のときのみ使用。これ以上近いと最大アルファになる距離。")]
        private float minFadeDistance = 5f;

        [Header("矢印の基準アルファ値")]
        [SerializeField]
        [Range(0f, 1f)]
        private float baseAlpha = 1f;

        // キャッシュ
        private Canvas _canvas;
        private RectTransform _canvasRectTransform;
        private Image _arrowImage;

        // 矢印を一時的に非表示にしたい場合に使えるフラグ
        public bool IsTemporarilyHidden { get; set; }

        private void Awake()
        {
            // Canvas を探す
            _canvas = GetComponentInParent<Canvas>();
            if (_canvas == null)
            {
                Debug.LogError("[ObjectiveMarker] 親階層に Canvas が見つかりません。UI階層の下に配置してください。", this);
                enabled = false;
                return;
            }

            _canvasRectTransform = _canvas.GetComponent<RectTransform>();

            if (arrowRectTransform == null)
            {
                Debug.LogError("[ObjectiveMarker] arrowRectTransform が未設定です。矢印アイコンの RectTransform をアサインしてください。", this);
                enabled = false;
                return;
            }

            _arrowImage = arrowRectTransform.GetComponent<Image>();
            if (_arrowImage == null)
            {
                Debug.LogWarning("[ObjectiveMarker] 矢印に Image コンポーネントが見つかりません。フェード機能は動作しません。", this);
            }

            // カメラが未設定なら、メインカメラを自動取得
            if (targetCamera == null)
            {
                targetCamera = Camera.main;
            }
        }

        private void Update()
        {
            if (IsTemporarilyHidden)
            {
                SetArrowActive(false);
                return;
            }

            if (targetCamera == null || objectiveTarget == null)
            {
                // 必要な参照がなければ何もしない
                SetArrowActive(false);
                return;
            }

            UpdateArrowPositionAndRotation();
        }

        /// <summary>
        /// 矢印の位置・回転・透明度を毎フレーム更新するメイン処理
        /// </summary>
        private void UpdateArrowPositionAndRotation()
        {
            // 目的地のワールド座標
            Vector3 targetWorldPos = objectiveTarget.position;

            // カメラから見たスクリーン座標に変換
            Vector3 screenPos = targetCamera.WorldToScreenPoint(targetWorldPos);

            // カメラの前方にあるか(z > 0)をチェック
            bool isInFrontOfCamera = screenPos.z > 0f;

            // スクリーンサイズ
            float screenWidth = Screen.width;
            float screenHeight = Screen.height;

            // 目的地が画面内にあるかどうかの判定
            bool isInsideScreen =
                isInFrontOfCamera &&
                screenPos.x >= 0f && screenPos.x <= screenWidth &&
                screenPos.y >= 0f && screenPos.y <= screenHeight;

            // 画面内にある場合は、オプションに応じて矢印を隠す
            if (isInsideScreen && hideArrowWhenOnScreen)
            {
                SetArrowActive(false);
                return;
            }

            SetArrowActive(true);

            // 目的地の方向ベクトル(カメラ位置基準)
            Vector3 dirWorld = (targetWorldPos - targetCamera.transform.position).normalized;

            // カメラの前方を基準に、XZ平面上の2Dベクトルを作る
            // forward を基準にしたローカル座標系に変換してから、X(横)とZ(前)を取り出す
            Vector3 dirLocal = targetCamera.transform.InverseTransformDirection(dirWorld);
            Vector2 dirOnPlane = new Vector2(dirLocal.x, dirLocal.z);

            // 2Dベクトルがゼロに近い場合は回転させない
            if (dirOnPlane.sqrMagnitude > 0.0001f)
            {
                // 矢印を進行方向に向ける(Z+方向を前として回転)
                float angle = Mathf.Atan2(dirOnPlane.x, dirOnPlane.y) * Mathf.Rad2Deg;
                arrowRectTransform.rotation = Quaternion.Euler(0f, 0f, -angle);
            }

            // 画面端にクランプしたスクリーン座標を算出
            Vector2 clampedScreenPos = ClampToScreenBorder(screenPos, screenWidth, screenHeight, screenBorderPadding);

            // スクリーン座標をキャンバス座標(アンカー基準のローカル座標)に変換
            Vector2 canvasPos;
            if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
                    _canvasRectTransform,
                    clampedScreenPos,
                    _canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : targetCamera,
                    out canvasPos))
            {
                arrowRectTransform.anchoredPosition = canvasPos;
            }

            // 距離に応じたフェード
            if (useDistanceFade && _arrowImage != null)
            {
                float distance = Vector3.Distance(targetCamera.transform.position, targetWorldPos);
                float alpha = CalculateDistanceAlpha(distance);
                SetArrowAlpha(alpha);
            }
            else if (_arrowImage != null)
            {
                SetArrowAlpha(baseAlpha);
            }
        }

        /// <summary>
        /// スクリーン座標を画面端にクランプする。
        /// 中央方向に向かって、指定したマージン分だけ内側に寄せる。
        /// </summary>
        private Vector2 ClampToScreenBorder(Vector3 screenPos, float width, float height, float padding)
        {
            // カメラの後ろ側にある場合は、画面外の反対側に飛ばす
            if (screenPos.z < 0f)
            {
                screenPos.x = width - screenPos.x;
                screenPos.y = height - screenPos.y;
                screenPos.z = 0f;
            }

            // まずスクリーン内にクランプ
            float clampedX = Mathf.Clamp(screenPos.x, padding, width - padding);
            float clampedY = Mathf.Clamp(screenPos.y, padding, height - padding);

            // どの辺に近いかを判定し、その辺上に位置させる
            float leftDist = Mathf.Abs(clampedX - padding);
            float rightDist = Mathf.Abs(clampedX - (width - padding));
            float bottomDist = Mathf.Abs(clampedY - padding);
            float topDist = Mathf.Abs(clampedY - (height - padding));

            float minDist = Mathf.Min(leftDist, rightDist, bottomDist, topDist);

            if (minDist == leftDist)
            {
                clampedX = padding;
            }
            else if (minDist == rightDist)
            {
                clampedX = width - padding;
            }
            else if (minDist == bottomDist)
            {
                clampedY = padding;
            }
            else
            {
                clampedY = height - padding;
            }

            return new Vector2(clampedX, clampedY);
        }

        /// <summary>
        /// 距離に応じたアルファ値を計算する。
        /// minFadeDistance 以内なら baseAlpha、
        /// maxFadeDistance 以上なら baseAlpha * 0.3f まで下げる。
        /// </summary>
        private float CalculateDistanceAlpha(float distance)
        {
            if (distance <= minFadeDistance)
            {
                return baseAlpha;
            }

            if (distance >= maxFadeDistance)
            {
                return baseAlpha * 0.3f;
            }

            float t = Mathf.InverseLerp(maxFadeDistance, minFadeDistance, distance);
            float minAlpha = baseAlpha * 0.3f;
            return Mathf.Lerp(minAlpha, baseAlpha, t);
        }

        /// <summary>
        /// 矢印オブジェクトの有効/無効を切り替える
        /// </summary>
        private void SetArrowActive(bool isActive)
        {
            if (arrowRectTransform == null) return;

            if (arrowRectTransform.gameObject.activeSelf != isActive)
            {
                arrowRectTransform.gameObject.SetActive(isActive);
            }
        }

        /// <summary>
        /// 矢印の透明度を設定する(Image.color の alpha だけ変更)
        /// </summary>
        private void SetArrowAlpha(float alpha)
        {
            if (_arrowImage == null) return;

            Color c = _arrowImage.color;
            c.a = alpha;
            _arrowImage.color = c;
        }

        // ====== 外部から目的地を差し替えたいとき用のAPI ======

        /// <summary>
        /// 目的地を動的に変更する。
        /// 例: ステージ中に複数の目的地を順番に指し示したい場合など。
        /// </summary>
        public void SetObjectiveTarget(Transform newTarget)
        {
            objectiveTarget = newTarget;
        }
    }
}

使い方の手順

ここでは、プレイヤーが広いフィールドを走り回り、遠くにあるゴール地点を矢印で案内するケースを例にします。

  1. キャンバスと矢印アイコンを用意する
    • Hierarchy で UI > Canvas を作成(Render Mode は Screen Space - Overlay 推奨)。
    • Canvas の子として UI > Image を追加し、矢印っぽいスプライトを割り当てます(無ければ三角形でもOK)。
    • Image の RectTransform を少し小さめ(例: 64×64)に調整しておきます。
  2. ObjectiveMarker コンポーネントを追加する
    • 矢印の Image オブジェクトに ObjectiveMarker スクリプトをアタッチします。
    • インスペクターで以下を設定します。
      • Target Camera:プレイヤーを映しているカメラ(通常は Main Camera)。未設定なら自動で Camera.main が使われます。
      • Objective Target:目的地となるオブジェクトの Transform(例: ゴールのフラッグ)。
      • Arrow Rect Transform:自分自身の RectTransform(ドラッグ&ドロップ)。
      • Screen Border Padding:矢印をどれだけ画面の内側に寄せるか(例: 50)。
      • Hide Arrow When On Screen:目的地が画面内に入ったら矢印を消したい場合は ON。
      • Use Distance Fade:距離によるフェード演出を使いたい場合は ON。
  3. 目的地オブジェクトを配置する
    • シーン上に「Goal」などの空オブジェクトを作成し、ゴール位置に配置します。
    • その Transform を Objective Target にアサインします。
    • プレイヤーからかなり離しておくと、矢印が画面端に表示されるのが分かりやすいです。
  4. プレイして挙動を確認する
    • 再生して、カメラを動かしつつゴールから目をそらしてみましょう。
    • ゴールが画面外に出た瞬間、画面端に矢印が現れ、ゴール方向を指し示すはずです。
    • ゴールが画面内に入ると、Hide Arrow When On Screen が ON の場合は自動的に矢印が消えます。

同じ仕組みを、

  • プレイヤーが追いかけるべき逃走中の敵キャラ
  • ダンジョン内の次の階段や出口
  • フィールドに落ちている大事なアイテム

などの Transform に差し替えるだけで、簡単に「目的地マーカー」を再利用できます。プレイヤーの移動ロジックや敵AIとは完全に分離されているので、Prefab 化して別シーンでもそのまま使い回せるのがポイントですね。

メリットと応用

この ObjectiveMarker コンポーネントを使うメリットは、主に次のようなものがあります。

  • 責務が明確
    「ワールド上の目的地 → 画面端の矢印」という変換だけに責務を絞っているので、コードが読みやすく、バグが出たときも原因を追いやすいです。
  • Prefab として再利用しやすい
    矢印 UI と ObjectiveMarker をまとめて Prefab 化しておけば、別のシーンでも「目的地の Transform だけ差し替える」だけで利用できます。レベルデザイナーが自分でゴールを差し替えられるのも嬉しいですね。
  • レベルデザインが楽になる
    広いマップや入り組んだダンジョンで、プレイヤーが迷子になりにくくなります。難易度調整として「迷いやすいステージだけ矢印を出す」「ボス戦だけ矢印を消す」といった運用もしやすいです。
  • 他のコンポーネントと独立している
    プレイヤーの移動コンポーネントやゲームマネージャーに矢印ロジックを書かないので、機能追加や差し替えがしやすくなります。「別のUIデザインにしたい」「距離に応じて色も変えたい」といった変更も、このコンポーネントだけを触ればOKです。

改造案:距離が近いときに矢印を点滅させる

例えば、「目的地がすぐ近くにあるときは、矢印を点滅させて注意を引きたい」という場合は、次のようなメソッドを追加してみるのも良いですね。


/// <summary>
/// 一定距離以内に入ったら矢印を点滅させるサンプル。
/// Update() の最後あたりから呼び出す想定です。
/// </summary>
private void UpdateBlinkNearTarget(float blinkDistance, float blinkSpeed)
{
    if (_arrowImage == null || objectiveTarget == null || targetCamera == null)
    {
        return;
    }

    float distance = Vector3.Distance(targetCamera.transform.position, objectiveTarget.position);

    if (distance <= blinkDistance)
    {
        // sin波で 0〜1 を往復させる
        float t = (Mathf.Sin(Time.time * blinkSpeed) + 1f) * 0.5f;
        float alpha = Mathf.Lerp(0.2f, baseAlpha, t);
        SetArrowAlpha(alpha);
    }
}

このように、小さなコンポーネントとして分割しておけば、「距離フェード」「点滅」「色変更」といった演出を段階的に追加しやすくなります。
巨大な Update に全部書くのではなく、「目的マーカー担当の小さなスクリプト」を育てていくイメージで設計していきましょう。