Unityを触り始めた頃は、つい「とりあえず全部Updateに書いてしまう」実装になりがちですよね。
敵の索敵、狙い、発射タイミング、エフェクト表示、SE再生…すべてを1つのスクリプトのUpdateに詰め込むと、気づけば数百行のGodクラスになってしまいます。
そうなると、
- 挙動を少し変えるだけで別の処理が壊れる
- 他の敵にロジックを流用しづらい
- デザイナーがパラメータを調整しにくい
といった問題が一気に噴き出してきます。
この記事では「遠距離からプレイヤーをロックオンし、レーザー照射後に高速弾を撃つ」という狙撃AIの挙動を、
「SniperAim」コンポーネントとして切り出し、1つの責務に絞ったコンポーネント指向の書き方で実装してみます。
【Unity】レーザーで狙いを定めて一撃必殺!「SniperAim」コンポーネント
狙撃AIに必要な要素を、このコンポーネントでは次のように分解します。
- プレイヤーを一定距離内で検知する
- プレイヤー方向を向き、レーザーで「予告照射」する
- 一定時間レーザーを当てた後、高速弾を発射する
- クールダウンを挟んで再度狙撃を行う
移動やHP管理などは別コンポーネントに任せ、「狙って撃つ」部分だけを担当させるのがポイントです。
SniperAim コンポーネントのフルコード
using UnityEngine;
/// <summary>
/// 遠距離からプレイヤーをロックオンし、
/// レーザー照射後に高速弾を撃つ狙撃AIコンポーネント。
///
/// 責務:
/// ・ターゲットの検知
/// ・レーザーによる狙いの可視化
/// ・弾丸の発射タイミング管理
///
/// 移動やHP管理は別コンポーネントに任せる想定です。
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class SniperAim : MonoBehaviour
{
// ====== ターゲット関連 ======
[Header("Target Settings")]
[SerializeField] private Transform target; // 狙う対象(通常はプレイヤー)
[SerializeField] private float detectionRange = 30f; // この距離以内なら狙撃を開始
[SerializeField] private LayerMask obstacleMask; // 障害物用レイヤー(壁など)
// ====== レーザー表示関連 ======
[Header("Laser Settings")]
[SerializeField] private Color laserColor = Color.red; // レーザーの色
[SerializeField] private float laserWidth = 0.05f; // レーザーの太さ
[SerializeField] private float lockOnTime = 1.5f; // ロックオンに必要なレーザー照射時間
// ====== 弾丸発射関連 ======
[Header("Bullet Settings")]
[SerializeField] private GameObject bulletPrefab; // 発射する弾丸プレハブ
[SerializeField] private Transform muzzle; // 発射位置(銃口)
[SerializeField] private float bulletSpeed = 50f; // 弾丸の初速
[SerializeField] private float fireCooldown = 2.0f; // 発射後のクールダウン時間
// ====== 回転関連 ======
[Header("Rotation Settings")]
[SerializeField] private float rotateSpeed = 5f; // ターゲット方向へ回転する速度
[SerializeField] private bool onlyRotateOnY = true; // Y軸のみ回転するか(タレット風)
// ====== デバッグ表示 ======
[Header("Debug")]
[SerializeField] private bool showDetectionRange = true;
// 内部状態管理
private LineRenderer lineRenderer;
private float currentLockOnTimer = 0f;
private float cooldownTimer = 0f;
private bool isTargetVisible = false;
private void Awake()
{
// 必須コンポーネントの取得
lineRenderer = GetComponent<LineRenderer>();
// LineRenderer の初期設定
lineRenderer.positionCount = 2;
lineRenderer.startWidth = laserWidth;
lineRenderer.endWidth = laserWidth;
lineRenderer.material = new Material(Shader.Find("Sprites/Default"));
lineRenderer.startColor = laserColor;
lineRenderer.endColor = laserColor;
lineRenderer.enabled = false; // 初期状態では非表示
}
private void Update()
{
if (target == null)
{
// ターゲットが設定されていなければ何もしない
lineRenderer.enabled = false;
return;
}
// クールダウン中ならタイマーを進める
if (cooldownTimer > 0f)
{
cooldownTimer -= Time.deltaTime;
// クールダウン中はレーザーを消しておく
lineRenderer.enabled = false;
currentLockOnTimer = 0f;
return;
}
// ターゲットとの距離と視線をチェック
UpdateTargetVisibility();
if (!isTargetVisible)
{
// 見えていない場合はロックオン解除
lineRenderer.enabled = false;
currentLockOnTimer = 0f;
return;
}
// ターゲット方向へ回転させる
RotateToTarget();
// レーザーを表示しつつロックオン時間を計測
UpdateLaserAndLockOn();
// ロックオン完了なら弾を撃つ
if (currentLockOnTimer >= lockOnTime)
{
Fire();
}
}
/// <summary>
/// ターゲットが射程内かつ、障害物に遮られていないかを判定します。
/// </summary>
private void UpdateTargetVisibility()
{
Vector3 toTarget = target.position - transform.position;
float distance = toTarget.magnitude;
// 射程外なら不可
if (distance > detectionRange)
{
isTargetVisible = false;
return;
}
// Raycastで障害物チェック
if (Physics.Raycast(transform.position, toTarget.normalized, out RaycastHit hit, distance, obstacleMask))
{
// 何かに遮られている
isTargetVisible = false;
}
else
{
isTargetVisible = true;
}
}
/// <summary>
/// ターゲット方向へスムーズに回転させます。
/// onlyRotateOnY が true の場合はY軸のみ回転します。
/// </summary>
private void RotateToTarget()
{
Vector3 direction = target.position - transform.position;
if (onlyRotateOnY)
{
direction.y = 0f; // 水平方向のみに制限
if (direction.sqrMagnitude <= 0.0001f)
{
return;
}
}
Quaternion targetRotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRotation,
rotateSpeed * Time.deltaTime
);
}
/// <summary>
/// レーザーの描画とロックオンタイマーの更新を行います。
/// </summary>
private void UpdateLaserAndLockOn()
{
if (muzzle == null)
{
muzzle = transform; // 銃口が未設定なら本体位置から撃つ
}
// レーザーの始点と終点を設定
Vector3 startPos = muzzle.position;
Vector3 endPos = target.position;
lineRenderer.enabled = true;
lineRenderer.SetPosition(0, startPos);
lineRenderer.SetPosition(1, endPos);
// ロックオン時間を加算
currentLockOnTimer += Time.deltaTime;
}
/// <summary>
/// 弾丸を生成して発射します。
/// </summary>
private void Fire()
{
if (bulletPrefab == null)
{
Debug.LogWarning("[SniperAim] bulletPrefab が設定されていません。弾を撃てません。", this);
ResetLockOn();
return;
}
if (muzzle == null)
{
muzzle = transform;
}
// 弾丸を生成
GameObject bulletInstance = Instantiate(
bulletPrefab,
muzzle.position,
muzzle.rotation
);
// Rigidbody があれば速度を与える
Rigidbody rb = bulletInstance.GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = muzzle.forward * bulletSpeed;
}
// ロックオン状態をリセットし、クールダウン開始
ResetLockOn();
cooldownTimer = fireCooldown;
}
/// <summary>
/// ロックオン状態とレーザー表示をリセットします。
/// </summary>
private void ResetLockOn()
{
currentLockOnTimer = 0f;
lineRenderer.enabled = false;
}
/// <summary>
/// プレイヤーを外部から設定するためのメソッド。
/// プレイヤー生成時にスクリプト側から渡す用途などに使えます。
/// </summary>
/// <param name="newTarget">狙いたい Transform</param>
public void SetTarget(Transform newTarget)
{
target = newTarget;
}
// シーンビューで射程範囲を可視化(デバッグ用)
private void OnDrawGizmosSelected()
{
if (!showDetectionRange) return;
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, detectionRange);
}
}
使い方の手順
ここでは、「スナイパータレットがプレイヤーを狙撃する」例で説明します。
-
プレハブの準備(スナイパー本体)
- 空のGameObjectを作成し、名前を
SniperTurretなどにします。 - モデル(タレットや敵キャラのメッシュ)を子オブジェクトとして配置します。
- レーザーの発射口になる子オブジェクトを作成し、名前を
Muzzleにしてタレットの先端に配置します。 LineRendererコンポーネントをSniperTurretに追加します(スクリプトが自動設定します)。SniperAimスクリプトをSniperTurretにアタッチします。
- 空のGameObjectを作成し、名前を
-
弾丸プレハブの準備
- 球やカプセルなどのPrimitiveで弾丸オブジェクトを作成します。
Rigidbodyを追加し、Use Gravityをオフにするか、重力を使うならDragを調整します。- 必要に応じて
Colliderを追加し、レイヤーやタグを設定します。 - この弾丸をプレハブ化し、
Bulletという名前で保存します。 SniperTurretのSniperAimのbulletPrefabに、このBulletプレハブをドラッグ&ドロップで割り当てます。
-
ターゲット(プレイヤー)の設定
- シーン内のプレイヤーオブジェクト(例:
Player)を選択します。 SniperTurretを選択し、SniperAimのTargetフィールドに、Playerの Transform をドラッグ&ドロップで割り当てます。Muzzleフィールドに、先ほど作った子オブジェクトMuzzleを割り当てます。
- シーン内のプレイヤーオブジェクト(例:
-
パラメータ調整とテスト
Detection Rangeを 20〜40 くらいに設定し、どの距離から狙撃を開始するか調整します。Lock On Timeを 1.0〜2.0 にして、「レーザー照射の予告時間」を調整します。Bullet Speedを 40〜80 にして、高速弾の速さを調整します。Fire Cooldownを 1.5〜3.0 にして、連射間隔を調整します。Obstacle Maskに「壁」などのレイヤーを指定すると、壁越しには狙撃してこなくなります。- ゲームを再生し、プレイヤーを射程内に近づけてみて、レーザーが照射されてから弾が飛んでくるか確認しましょう。
同じ SniperAim プレハブを、
- 高台に置いた狙撃兵
- ステージ端に配置した自動タレット
- ボスの肩に乗った砲台
など、いろんな場所にポンポン置けるようになると、レベルデザインがかなり楽になりますね。
メリットと応用
SniperAim をコンポーネントとして切り出しておくと、次のようなメリットがあります。
- 責務が明確:このコンポーネントは「狙って撃つ」ことだけを担当するので、コードが読みやすい。
- 再利用しやすい:移動AIやHP管理を変えても、狙撃ロジックはそのまま別の敵に流用できます。
- プレハブ化しやすい:パラメータをインスペクタから調整できるため、デザイナーが速度や射程をいじりやすい。
- テストがしやすい:SniperAim単体で動作確認できるので、バグの切り分けが簡単です。
例えば、同じ SniperAim を使って、
- レーザーの色を青にして「冷凍ビームタレット」
- ロックオン時間を短くして「高速スナイパー」
- 弾速を遅くして「避けられる弾幕スナイパー」
など、バリエーションを簡単に作れます。
コンポーネントを差し替えるのではなく、同じコンポーネント+パラメータ違いで量産できるのが、プレハブ管理の大きな強みですね。
改造案:発射直前に警告フラッシュを入れる
「レーザー照射中の最後0.3秒だけ、レーザーを点滅させて危険度を演出したい」
といった要望が出た時も、責務を崩さずに小さな改造で対応できます。
例えば、以下のようなメソッドを追加して、UpdateLaserAndLockOn() の最後で呼び出すだけでも、それっぽい演出になります。
/// <summary>
/// 発射直前の警告として、レーザーを点滅させる簡易演出。
/// </summary>
private void UpdateWarningFlash()
{
// ロックオン完了の0.3秒前から点滅開始
float warningDuration = 0.3f;
float remaining = lockOnTime - currentLockOnTimer;
if (remaining <= warningDuration && remaining > 0f)
{
// 速めに点滅させる(時間によってON/OFFを切り替え)
float flashSpeed = 20f;
bool visible = Mathf.FloorToInt(Time.time * flashSpeed) % 2 == 0;
lineRenderer.enabled = visible;
}
else
{
// 通常時は常に表示
lineRenderer.enabled = true;
}
}
このように、1つのコンポーネントに「狙撃AI」という明確な役割を持たせておくと、
演出や難易度調整の要望にも、小さな関数追加だけで応えやすくなります。
ぜひ、自分のプロジェクトでも「巨大なUpdate」ではなく、小さなコンポーネントに分割するスタイルを意識してみてください。
