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;
}
}
}
使い方の手順
ここでは、プレイヤーが広いフィールドを走り回り、遠くにあるゴール地点を矢印で案内するケースを例にします。
-
キャンバスと矢印アイコンを用意する
- Hierarchy で
UI > Canvasを作成(Render Mode はScreen Space - Overlay推奨)。 - Canvas の子として
UI > Imageを追加し、矢印っぽいスプライトを割り当てます(無ければ三角形でもOK)。 - Image の RectTransform を少し小さめ(例: 64×64)に調整しておきます。
- Hierarchy で
-
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。
- Target Camera:プレイヤーを映しているカメラ(通常は Main Camera)。未設定なら自動で
- 矢印の Image オブジェクトに
-
目的地オブジェクトを配置する
- シーン上に「Goal」などの空オブジェクトを作成し、ゴール位置に配置します。
- その Transform を
Objective Targetにアサインします。 - プレイヤーからかなり離しておくと、矢印が画面端に表示されるのが分かりやすいです。
-
プレイして挙動を確認する
- 再生して、カメラを動かしつつゴールから目をそらしてみましょう。
- ゴールが画面外に出た瞬間、画面端に矢印が現れ、ゴール方向を指し示すはずです。
- ゴールが画面内に入ると、
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 に全部書くのではなく、「目的マーカー担当の小さなスクリプト」を育てていくイメージで設計していきましょう。
