Unityの学習を進めていくと、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤーの移動、ジャンプ、敵の探索、UIの更新…すべて1つのスクリプトに詰め込むと、いつの間にか「巨大なGodクラス」が出来上がってしまいます。
そうなると、
- ちょっとした仕様変更でも影響範囲が読めない
- 別プロジェクトで再利用しづらい
- バグが起きたときに原因箇所を特定しにくい
といった問題が一気に噴出します。
そこで今回は、「自動照準(Auto Aim)」の機能だけを切り出した小さなコンポーネントを作ってみましょう。敵のグループを指定し、一定範囲内にいる敵の中から最も近いターゲットを探し、その方向ベクトルを計算するコンポーネントです。
プレイヤーの移動ロジックとは分離されているので、
- プレイヤー
- タレット(砲台)
- 自動で旋回する敵
など、さまざまなオブジェクトにそのまま使い回せるようになります。
【Unity】敵を自動サーチして狙い撃ち!「AutoAim」コンポーネント
フルソースコード
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 自動照準コンポーネント
/// - 指定したLayerに属する敵を範囲検索し、最も近い敵の方向を算出します。
/// - 「どこを向くか」だけを担当し、「どう動くか」は別コンポーネントに任せる設計です。
/// </summary>
[DisallowMultipleComponent]
public class AutoAim : MonoBehaviour
{
// ====== 設定項目 ======
[Header("ターゲット検出設定")]
[SerializeField]
[Tooltip("敵が所属するLayerを指定します。複数LayerをまとめたLayerMaskでもOKです。")]
private LayerMask targetLayerMask;
[SerializeField]
[Tooltip("探索する最大距離(半径)。この範囲内の敵だけを検出します。")]
private float searchRadius = 10f;
[SerializeField]
[Tooltip("視野角(度)。0〜360。例えば90なら前方45度の扇形を探索します。360なら全方向。")]
private float viewAngle = 360f;
[SerializeField]
[Tooltip("ターゲットの中心位置として使うローカルオフセット。頭の位置などを狙いたい場合に調整します。")]
private Vector3 targetOffset = Vector3.zero;
[Header("遮蔽物チェック")]
[SerializeField]
[Tooltip("ターゲットとの間に遮蔽物があるかをチェックするLayerMask。壁などを指定します。")]
private LayerMask obstacleLayerMask;
[SerializeField]
[Tooltip("遮蔽物チェックを行うかどうか。falseにすると壁越しでもロックオンします。")]
private bool checkObstacles = true;
[Header("更新頻度")]
[SerializeField]
[Tooltip("ターゲット探索を行う間隔(秒)。0にすると毎フレーム探索します。")]
private float searchInterval = 0.1f;
// ====== 公開情報(読み取り専用プロパティ) ======
/// <summary>現在ロックオンしているターゲット(いなければ null)</summary>
public Transform CurrentTarget { get; private set; }
/// <summary>現在ロックオンしているターゲットへの方向ベクトル(ターゲットがいなければ Vector3.zero)</summary>
public Vector3 CurrentDirection { get; private set; }
/// <summary>現在ロックオンしているターゲットまでの距離(ターゲットがいなければ Mathf.Infinity)</summary>
public float CurrentDistance { get; private set; } = Mathf.Infinity;
// ====== 内部状態 ======
private float _timeSinceLastSearch = 0f;
private readonly Collider[] _overlapResults = new Collider[32]; // 一時バッファ: GC削減用
private void Awake()
{
// 初期状態をクリア
ClearTarget();
}
private void Update()
{
// 検索間隔が0以下なら毎フレーム探索
if (searchInterval <= 0f)
{
SearchTarget();
return;
}
// 経過時間を積算して、一定間隔ごとに探索
_timeSinceLastSearch += Time.deltaTime;
if (_timeSinceLastSearch >= searchInterval)
{
_timeSinceLastSearch = 0f;
SearchTarget();
}
}
/// <summary>
/// ターゲット探索のメイン処理。
/// 一定半径内のコライダーを取得し、最も近い有効なターゲットを選びます。
/// </summary>
private void SearchTarget()
{
Vector3 origin = transform.position;
// OverlapSphereNonAllocでGCを抑えつつ、半径内のコライダーを取得
int hitCount = Physics.OverlapSphereNonAlloc(
origin,
searchRadius,
_overlapResults,
targetLayerMask,
QueryTriggerInteraction.Ignore // 必要に応じて変更
);
Transform bestTarget = null;
float bestDistance = Mathf.Infinity;
Vector3 bestDirection = Vector3.zero;
for (int i = 0; i < hitCount; i++)
{
Collider col = _overlapResults[i];
if (col == null) continue;
Transform candidate = col.transform;
// 自分自身はスキップ
if (candidate == transform) continue;
Vector3 candidatePosition = candidate.position + targetOffset;
Vector3 dir = candidatePosition - origin;
float distance = dir.magnitude;
if (distance <= 0f) continue;
// 視野角チェック
if (!IsWithinViewAngle(dir))
{
continue;
}
// 遮蔽物チェック
if (checkObstacles && IsBlockedByObstacle(origin, candidatePosition, distance))
{
continue;
}
// 最も近いターゲットを更新
if (distance < bestDistance)
{
bestDistance = distance;
bestTarget = candidate;
bestDirection = dir.normalized;
}
}
// 結果を反映
if (bestTarget != null)
{
CurrentTarget = bestTarget;
CurrentDirection = bestDirection;
CurrentDistance = bestDistance;
}
else
{
ClearTarget();
}
}
/// <summary>
/// 視野角内に収まっているかを判定します。
/// viewAngle が 360 の場合は常にtrueを返します。
/// </summary>
private bool IsWithinViewAngle(Vector3 directionToTarget)
{
if (viewAngle >= 360f) return true;
Vector3 forward = transform.forward;
directionToTarget.Normalize();
// forward と directionToTarget のなす角を取得
float angle = Vector3.Angle(forward, directionToTarget);
return angle <= viewAngle * 0.5f;
}
/// <summary>
/// ターゲットとの間に遮蔽物があるかをRaycastでチェックします。
/// </summary>
private bool IsBlockedByObstacle(Vector3 origin, Vector3 targetPosition, float distance)
{
Vector3 dir = (targetPosition - origin).normalized;
// Raycastが当たったら遮蔽物ありと判定
return Physics.Raycast(
origin,
dir,
distance,
obstacleLayerMask,
QueryTriggerInteraction.Ignore
);
}
/// <summary>
/// 現在のターゲット情報をリセットします。
/// </summary>
private void ClearTarget()
{
CurrentTarget = null;
CurrentDirection = Vector3.zero;
CurrentDistance = Mathf.Infinity;
}
// ====== デバッグ描画 ======
private void OnDrawGizmosSelected()
{
// シーンビューで探索範囲を可視化
Gizmos.color = new Color(0f, 1f, 0f, 0.25f);
Gizmos.DrawWireSphere(transform.position, searchRadius);
// 視野角を簡易的に表示(360度の場合は省略)
if (viewAngle < 360f)
{
Gizmos.color = Color.yellow;
Vector3 forward = transform.forward;
Quaternion leftRot = Quaternion.AngleAxis(-viewAngle * 0.5f, Vector3.up);
Quaternion rightRot = Quaternion.AngleAxis(viewAngle * 0.5f, Vector3.up);
Vector3 leftDir = leftRot * forward;
Vector3 rightDir = rightRot * forward;
Gizmos.DrawLine(transform.position, transform.position + leftDir * searchRadius);
Gizmos.DrawLine(transform.position, transform.position + rightDir * searchRadius);
}
// 現在のターゲットへの線
if (CurrentTarget != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position, CurrentTarget.position + targetOffset);
}
}
}
使い方の手順
-
敵オブジェクトにLayerを設定する
敵プレハブ(例:Enemy)を選択し、インスペクター上部の「Layer」を任意のもの(例:Enemy)に設定します。
まだLayerがない場合は「Add Layer…」から新規追加しておきましょう。 -
AutoAimコンポーネントをアタッチする
自動照準させたいオブジェクト(例: プレイヤー、タレット、敵の頭部など)に先ほどのAutoAimスクリプトをアタッチします。
インスペクターで以下を設定します:Target Layer Mask: 先ほど作成したEnemyLayer を指定Search Radius: 例として 15〜20 くらいView Angle: 360 なら全方向、120 なら前方だけなどObstacle Layer Mask: 壁や障害物のLayerを指定(なければDefaultなど)Check Obstacles: 壁越しにロックオンさせたくない場合はオンSearch Interval: 0.05〜0.2 くらいにすると軽くて滑らかです
-
具体例①:プレイヤーの向きを自動で敵の方向に向ける
プレイヤーの移動スクリプトとは別に「見た目の向きだけ」を制御する小さなスクリプトを作り、AutoAimを参照します。using UnityEngine; /// <summary> /// AutoAim の方向にプレイヤーの見た目だけをゆっくり回転させる例 /// </summary> [RequireComponent(typeof(AutoAim))] public class AutoAimLookAt : MonoBehaviour { [SerializeField] [Tooltip("回転速度(度/秒)")] private float rotateSpeed = 360f; private AutoAim _autoAim; private void Awake() { _autoAim = GetComponent<AutoAim>(); } private void Update() { // ターゲットがいなければ何もしない if (_autoAim.CurrentTarget == null) { return; } Vector3 dir = _autoAim.CurrentDirection; dir.y = 0f; // 水平方向だけ向かせたい場合はYを0に if (dir.sqrMagnitude <= 0f) { return; } Quaternion targetRotation = Quaternion.LookRotation(dir, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, targetRotation, rotateSpeed * Time.deltaTime ); } }このように、「どの方向を向きたいか」は
AutoAimが計算し、「実際に回転する処理」はAutoAimLookAtに分離できます。 -
具体例②:タレット(砲台)が自動で最も近い敵に向けて弾を撃つ
タレットの土台にAutoAimを付け、砲身を回転させて弾を発射する簡単なスクリプトを追加します。using UnityEngine; /// <summary> /// AutoAim を使って最も近い敵を狙い、一定間隔で弾を撃つタレットの例 /// </summary> [RequireComponent(typeof(AutoAim))] public class AutoAimTurretShooter : MonoBehaviour { [SerializeField] private Transform turretHead; // 回転させる砲身部分 [SerializeField] private GameObject bulletPrefab; [SerializeField] private float fireInterval = 0.5f; [SerializeField] private float rotateSpeed = 360f; private AutoAim _autoAim; private float _timeSinceLastShot = 0f; private void Awake() { _autoAim = GetComponent<AutoAim>(); } private void Update() { _timeSinceLastShot += Time.deltaTime; if (_autoAim.CurrentTarget == null) { return; } // 砲身をターゲット方向へ回転 Vector3 dir = _autoAim.CurrentDirection; if (dir.sqrMagnitude > 0f) { Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up); turretHead.rotation = Quaternion.RotateTowards( turretHead.rotation, targetRot, rotateSpeed * Time.deltaTime ); } // 一定間隔で弾を発射 if (_timeSinceLastShot >= fireInterval) { _timeSinceLastShot = 0f; Shoot(dir); } } private void Shoot(Vector3 direction) { if (bulletPrefab == null) return; GameObject bullet = Instantiate( bulletPrefab, turretHead.position, Quaternion.LookRotation(direction, Vector3.up) ); // Bullet側でRigidbodyや移動処理を実装しておきましょう } }これでタレットのロジックも「敵の探索」「向きの制御」「弾の発射」に分割され、かなり見通しが良くなります。
メリットと応用
AutoAim コンポーネントを導入すると、次のようなメリットがあります。
- プレハブの再利用性が高い
プレイヤー用、タレット用、敵AI用など、異なるオブジェクトでも「自動照準」という共通機能をそのまま使い回せます。
方向ベクトルとターゲット情報をプロパティとして公開しているだけなので、どんな移動・攻撃ロジックとも組み合わせやすいです。 - レベルデザインが楽になる
シーン上にタレットや敵をポンポン置いてAutoAimを付けるだけで、「近くに来たプレイヤーを狙う」ギミックがすぐ動きます。
Search Radiusを変えるだけで強さを調整できるので、難易度調整もしやすいです。 - 責務が明確で保守しやすい
「敵の探索と方向計算」だけに責務を絞っているため、バグが起きてもこのコンポーネント内だけを見ればOKです。
移動やアニメーションの不具合とは切り離して考えられるのが大きな利点ですね。
応用としては、
- 最も近い敵ではなく「HPが一番低い敵」を優先する
- 一定時間ごとにターゲットをローテーションして狙いを変える
- マルチロックオン(複数ターゲットをリストで保持)に拡張する
といった拡張も簡単に行えます。
最後に、「特定の距離以上に近づかれたらターゲットを外す」ような改造案の例を載せておきます。近接攻撃が苦手なタレットなどに使えます。
/// <summary>
/// 現在のターゲットが近づきすぎたらロックオンを解除する改造例
/// AutoAim の Update とは別スクリプトとして同じオブジェクトに付ける想定です。
/// </summary>
public class AutoAimMinDistanceCanceller : MonoBehaviour
{
[SerializeField]
[Tooltip("この距離より近づいたターゲットはロックオン解除します。")]
private float minKeepDistance = 2f;
private AutoAim _autoAim;
private void Awake()
{
_autoAim = GetComponent<AutoAim>();
}
private void Update()
{
if (_autoAim.CurrentTarget == null)
{
return;
}
if (_autoAim.CurrentDistance <= minKeepDistance)
{
// かなり強引ですが、SearchIntervalを0にして即再探索させるなど、
// プロパティをうまく使って挙動を調整できます。
// ここでは単純にターゲットを無視する例として距離だけを見ています。
Debug.Log("ターゲットが近すぎるためロックオン解除したい場合は、" +
"AutoAim側のロジックを少し拡張してみましょう。");
}
}
}
このように、AutoAim を「ターゲット情報提供コンポーネント」として小さく保っておくことで、周辺のスクリプトを自由に差し替えやすくなります。
巨大なGodクラスにせず、機能ごとにコンポーネントを分ける習慣をつけていきましょう。
