Unityのシューティングやアクションを作っていると、つい Update() の中に「入力」「移動」「カメラ」「攻撃」「UI更新」など、全部詰め込んでしまいがちですよね。
とりあえず動くものの、数百行になった PlayerController を開くたびに「どこを触ればいいんだ…」となるのは、あるあるです。

特に「偏差射撃(動くターゲットの未来位置を狙う)」のような少し数学が絡む処理は、巨大なクラスの中に埋もれさせると、あとで調整や再利用がとても大変になります。

そこでこの記事では、「ターゲットの現在の速度を考慮し、数秒後の未来位置を狙って撃つ」機能だけを、ひとつの小さなコンポーネントに切り出してみましょう。
プレイヤーでも敵でも、弾発射口でも、任意のオブジェクトにアタッチして使い回せる偏差射撃コンポーネントを作っていきます。

【Unity】動くターゲットを未来位置で撃ち抜く!「PredictAim」コンポーネント

今回作る PredictAim は、

  • ターゲットの 現在位置現在速度 を取得
  • 自分(発射位置)からの距離と弾速から「着弾までの時間」をざっくり推定
  • その時間だけターゲットが動いたと仮定した「未来位置」を計算
  • その未来位置の方向に発射・照準を向ける

といった処理を行うコンポーネントです。
弾丸の発射は Rigidbody を使ったシンプルな実装にしておき、「どこを狙うか」だけをこのコンポーネントの責務にします。

フルコード:PredictAim.cs


using UnityEngine;

/// <summary>
/// ターゲットの現在速度を考慮して、未来位置を狙う偏差射撃コンポーネント。
/// - 発射位置となるオブジェクトにアタッチして使う想定
/// - ターゲットには Rigidbody か、もしくは Transform だけでも可
/// - 弾速を指定すると、その弾が届くまでの時間を推定して未来位置を計算
/// </summary>
public class PredictAim : MonoBehaviour
{
    [Header("ターゲット設定")]
    [SerializeField] private Transform targetTransform;    // 追いかけたいターゲット
    [SerializeField] private Rigidbody targetRigidbody;    // ターゲットのRigidbody(あれば速度をここから取得)

    [Header("射撃パラメータ")]
    [SerializeField] private float projectileSpeed = 20f;  // 弾速(m/s)
    [SerializeField] private float maxPredictionTime = 3f; // 未来位置の予測に使う最大時間(秒)

    [Header("発射設定")]
    [SerializeField] private Rigidbody projectilePrefab;   // 発射する弾のPrefab(Rigidbody付き)
    [SerializeField] private Transform muzzleTransform;    // 発射口(nullならこのオブジェクトのTransformを使用)
    [SerializeField] private float fireCooldown = 0.2f;    // 連射間隔(秒)

    [Header("デバッグ描画")]
    [SerializeField] private bool drawDebugGizmos = true;  // シーンビューで軌道などを描画するか
    [SerializeField] private Color aimLineColor = Color.cyan;
    [SerializeField] private Color futurePointColor = Color.yellow;

    // 内部状態
    private float _lastFireTime = -999f;
    private Vector3 _lastPredictedPoint;  // 直近で計算した未来位置
    private bool _hasValidPrediction;

    private void Reset()
    {
        // コンポーネントをアタッチしたときに、よく使う参照を自動で拾う
        muzzleTransform = transform;
    }

    private void Update()
    {
        // ターゲットが設定されていない場合は何もしない
        if (targetTransform == null)
        {
            _hasValidPrediction = false;
            return;
        }

        // 未来位置を計算
        _hasValidPrediction = TryGetPredictedPoint(out _lastPredictedPoint);

        // 未来位置が有効なら、その方向を向く(発射口 or 自身)
        if (_hasValidPrediction)
        {
            Transform pivot = muzzleTransform != null ? muzzleTransform : transform;
            Vector3 direction = _lastPredictedPoint - pivot.position;

            if (direction.sqrMagnitude > 0.0001f)
            {
                // Y軸を上とした一般的な3Dゲームを想定
                pivot.rotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
            }
        }

        // ここでは「左クリックで撃つ」という簡単な入力にしておく
        // 入力システムはプロジェクトに合わせて差し替えてOK
        if (Input.GetMouseButton(0))
        {
            TryFire();
        }
    }

    /// <summary>
    /// 偏差射撃に基づいて弾を発射する。
    /// 未来位置が計算できない場合は、ターゲットの現在位置を狙う。
    /// </summary>
    private void TryFire()
    {
        // クールダウン中なら撃たない
        if (Time.time - _lastFireTime < fireCooldown)
        {
            return;
        }

        if (projectilePrefab == null)
        {
            Debug.LogWarning($"{nameof(PredictAim)}: projectilePrefab が設定されていません。");
            return;
        }

        Transform pivot = muzzleTransform != null ? muzzleTransform : transform;

        // 発射位置と向き
        Vector3 spawnPosition = pivot.position;
        Quaternion spawnRotation = pivot.rotation;

        // 実際に弾を生成
        Rigidbody projectileInstance = Instantiate(projectilePrefab, spawnPosition, spawnRotation);

        // 進行方向(未来位置が無効なら、とりあえず前方)
        Vector3 shotDirection;

        if (_hasValidPrediction)
        {
            shotDirection = (_lastPredictedPoint - spawnPosition).normalized;
        }
        else if (targetTransform != null)
        {
            shotDirection = (targetTransform.position - spawnPosition).normalized;
        }
        else
        {
            shotDirection = pivot.forward;
        }

        // 弾に速度を与える
        projectileInstance.velocity = shotDirection * projectileSpeed;

        _lastFireTime = Time.time;
    }

    /// <summary>
    /// ターゲットの未来位置を計算する。
    /// - 成功したら true と予測位置を返す
    /// - 条件が足りない場合や計算不能な場合は false
    /// </summary>
    public bool TryGetPredictedPoint(out Vector3 predictedPoint)
    {
        predictedPoint = Vector3.zero;

        if (targetTransform == null)
        {
            return false;
        }

        Transform pivot = muzzleTransform != null ? muzzleTransform : transform;

        Vector3 shooterPosition = pivot.position;
        Vector3 targetPosition = targetTransform.position;

        // ターゲットの速度を取得
        Vector3 targetVelocity = GetTargetVelocity();

        // ターゲットがほぼ静止しているなら、現在位置を返して終了
        if (targetVelocity.sqrMagnitude < 0.0001f || projectileSpeed <= 0f)
        {
            predictedPoint = targetPosition;
            return true;
        }

        // 距離から「弾が届くまでの時間」をざっくり推定
        float distance = Vector3.Distance(shooterPosition, targetPosition);
        float estimatedTime = distance / projectileSpeed;

        // あまりにも長い時間は予測精度が落ちるので、上限を設ける
        estimatedTime = Mathf.Clamp(estimatedTime, 0f, maxPredictionTime);

        // 未来位置 = 現在位置 + 速度 * 時間
        predictedPoint = targetPosition + targetVelocity * estimatedTime;

        return true;
    }

    /// <summary>
    /// ターゲットの現在速度を取得する。
    /// - targetRigidbody があればそこから取得
    /// - なければ Transform の前フレーム位置との差分から推定(簡易)
    /// </summary>
    private Vector3 GetTargetVelocity()
    {
        if (targetRigidbody != null)
        {
            return targetRigidbody.velocity;
        }

        // Rigidbody がない場合に備えて、Transform ベースの簡易推定を行う
        // 毎フレームの差分を取るには状態を保持する必要があるので、
        // ここでは「動いていない」と仮定する簡易版にしておく。
        // 本格的にやりたい場合は、別コンポーネントで位置履歴を管理するとよいです。
        return Vector3.zero;
    }

    private void OnDrawGizmos()
    {
        if (!drawDebugGizmos)
        {
            return;
        }

        Transform pivot = muzzleTransform != null ? muzzleTransform : transform;

        // 発射方向の線
        Gizmos.color = aimLineColor;
        Gizmos.DrawRay(pivot.position, pivot.forward * 5f);

        // 未来位置の可視化
        if (_hasValidPrediction)
        {
            Gizmos.color = futurePointColor;
            Gizmos.DrawSphere(_lastPredictedPoint, 0.2f);
            Gizmos.DrawLine(pivot.position, _lastPredictedPoint);
        }
    }
}

このコンポーネントは「未来位置の計算と発射」という責務に絞っています。
入力の方法や、弾の見た目・ダメージ処理などは、別コンポーネントに切り出して組み合わせると、さらに保守しやすくなります。

使い方の手順

ここでは代表的な3つの使い方を例に、手順①〜④で説明します。

例1:敵タレットが動くプレイヤーを偏差射撃する

  1. シーンに敵タレット用のGameObjectを作成
    例えば「EnemyTurret」という空のオブジェクトを作り、その子に見た目モデル(砲台メッシュなど)を配置します。砲身の先端に「Muzzle」という空オブジェクトを作り、そこから弾を発射する想定にします。
  2. PredictAimコンポーネントをアタッチ
    「EnemyTurret」または「Muzzle」に PredictAim を追加します。
    • Target Transform:プレイヤーのTransformをドラッグ&ドロップ
    • Target Rigidbody:プレイヤーのRigidbodyをドラッグ&ドロップ
    • Projectile Prefab:Rigidbody付きの弾Prefabを設定
    • Muzzle Transform:砲身先端の「Muzzle」を設定
    • Projectile Speed:弾速(例:30)
    • Max Prediction Time:予測最大時間(例:2)
    • Fire Cooldown:連射間隔(例:0.5)
  3. 弾Prefabを用意
    球(Sphere)などを作成し、Rigidbody を追加してPrefab化します。
    重力を無効にしたい場合は Use Gravity をオフにしておきましょう。
  4. プレイして挙動を確認
    ゲームを再生し、プレイヤーを動かしてみてください。
    タレットはプレイヤーの少し先の位置を狙って砲身を向け、左クリック入力に合わせて偏差射撃してくれます。
    実際のゲームでは、左クリックの代わりに「一定間隔で自動発射するAIコンポーネント」などと組み合わせるとよいですね。

例2:プレイヤーが高速で動くボスの弱点を偏差射撃する

  1. プレイヤーの武器オブジェクトを用意
    プレイヤーの子オブジェクトとして「Gun」などを作成し、その先端に「Muzzle」を配置します。
  2. GunにPredictAimをアタッチ
    今度は Target Transform にボスの弱点(例:WeakPointオブジェクト)のTransformを設定し、Target Rigidbody にボス本体のRigidbodyを設定します。
  3. 弾速・予測時間を調整
    プレイヤーの武器なので、敵タレットよりも弾速を速く(例:60)し、Max Prediction Time を短め(例:1秒)にすると、操作感がよくなります。
  4. Inputをプレイヤー用に差し替える
    サンプルでは Input.GetMouseButton(0) で発射していますが、プロジェクトのInput Systemに合わせて TryFire() を外部から呼ぶようにするときれいに分離できます。

例3:動く床から、先回りしてプレイヤーを撃つトラップ

  1. レール移動する床を用意
    例えば、一定速度で横移動する床にRigidbodyを付けて移動させます。
  2. 床の側面に砲台オブジェクトを作成
    動く床の子として「SideTurret」を作り、その先端に「Muzzle」を配置します。
  3. PredictAimをSideTurretにアタッチ
    Target Transform にプレイヤー、Target Rigidbody にプレイヤーのRigidbodyを設定します。床側も動いているので、相対速度の変化が面白い動きになります。
  4. Gizmosで未来位置を確認
    Draw Debug Gizmos をオンにしておくと、シーンビューで「どこを狙っているか」が可視化されるので、レベルデザイン中の調整がかなり楽になります。

メリットと応用

PredictAim をコンポーネントとして切り出すメリットはたくさんあります。

  • プレハブ化しやすい
    敵タレット、プレイヤー武器、トラップなど、「偏差射撃したい発射位置」にこのコンポーネントをアタッチしてPrefab化するだけで、どこでも同じロジックを再利用できます。
  • レベルデザインが楽になる
    シーン上で Projectile SpeedMax Prediction Time をいじるだけで、「当たりやすい」「避けやすい」偏差射撃を直感的に調整できます。
    巨大なスクリプトの中から数式を探して編集する必要がなくなります。
  • 責務が明確でテストしやすい
    「偏差射撃の計算が正しいか?」だけを単体で検証できます。
    入力、ダメージ、エフェクトなどと分離されているので、バグの原因も特定しやすいですね。
  • ターゲットを差し替えるだけで挙動を変えられる
    同じタレットPrefabでも、シーンごとに Target Transform を変えるだけで、プレイヤーを狙ったり、NPCを狙ったりと挙動を簡単に切り替えられます。

さらに、偏差射撃の計算部分をもう少し高度にする改造も簡単です。
例えば「ターゲットの加速度」や「重力」を考慮したり、「予測位置に届くまでの時間を二次方程式で厳密に求める」などの拡張も、TryGetPredictedPoint の中だけを差し替えればOKです。

以下は、ターゲットの速度が速いときだけ予測時間を短くする簡単な改造例です。


/// <summary>
/// ターゲットの速度に応じて予測時間をダイナミックに調整する例。
/// 速度が速いほど、短い時間だけ先を読むことで「外しすぎ」を防ぐ。
/// </summary>
private float GetAdaptivePredictionTime(float baseTime, Vector3 targetVelocity)
{
    // 速度の大きさ(m/s)
    float speed = targetVelocity.magnitude;

    // 例えば、0〜20m/s の範囲で 1.0〜0.3倍の係数をかける
    float t = Mathf.InverseLerp(0f, 20f, speed); // 0〜1
    float factor = Mathf.Lerp(1.0f, 0.3f, t);    // 速度が速いほど小さく

    return baseTime * factor;
}

この関数を TryGetPredictedPoint の中で使えば、「速いターゲットほど控えめに先読みする」ようなチューニングができます。
こうしたロジックを小さな関数として切り出しておくと、ゲームデザイナーと一緒に数値をいじりながら、気持ちいい偏差射撃を作り込んでいけますね。