【Unity】HitStopZoom (ヒットズーム) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unityでアクションゲームを作り始めると、つい Update() の中に「入力処理」「移動」「当たり判定」「エフェクト制御」「カメラ制御」などを全部まとめて書いてしまいがちですよね。
最初は動くのですが、

  • 処理が複雑になってバグの原因が分からない
  • 別のシーン・別のキャラで同じ演出を使い回したいのにコピペ地獄
  • カメラ演出だけ直したいのに、プレイヤーのコードまでいじる必要がある

といった「Godクラス化」の問題が一気に噴き出します。

そこでこの記事では、「攻撃ヒット時、一瞬だけカメラをターゲットへズームインさせる」機能を、カメラ専用の小さなコンポーネントとして切り出してみます。
プレイヤー側は「ヒットしたよ」とイベントを飛ばすだけ、ズーム演出は HitStopZoom コンポーネントに丸投げする構成ですね。

【Unity】一瞬の迫力を簡単追加!「HitStopZoom」コンポーネント

ここでは、以下のような仕様のコンポーネントを作ります。

  • カメラにアタッチして使う
  • スクリプトから PlayZoom(Transform target) を呼ぶと、
    カメラがターゲット方向に一瞬ズームインしてから、元の位置・FOVに戻る
  • ヒットストップっぽく、ズーム中は時間を少しだけスローにするオプション付き
  • 複数回連続で呼ばれても、綺麗に上書きして動作する

フルコード:HitStopZoom.cs


using System.Collections;
using UnityEngine;

namespace Samples.CameraEffects
{
    /// <summary>
    /// 攻撃ヒット時などに、一瞬だけカメラをターゲット側へズームインさせるコンポーネント。
    /// カメラにアタッチして、外部から PlayZoom を呼び出して使います。
    /// </summary>
    [RequireComponent(typeof(Camera))]
    public class HitStopZoom : MonoBehaviour
    {
        [Header("ズーム設定")]
        [SerializeField]
        [Tooltip("ズームイン時にカメラが近づく距離(ワールド座標単位)。正の値でターゲット方向へ近づきます。")]
        private float zoomDistance = 2.0f;

        [SerializeField]
        [Tooltip("ズームイン時のFOV(視野角)。現在のFOVからこの値まで補間します。0以下ならFOVは変更しません。")]
        private float zoomFov = 40f;

        [SerializeField]
        [Tooltip("ズームインにかける時間(秒)。短いほどキビキビした演出になります。")]
        private float zoomInDuration = 0.05f;

        [SerializeField]
        [Tooltip("ズームアウトにかける時間(秒)。少し長めにすると気持ちよく戻ります。")]
        private float zoomOutDuration = 0.15f;

        [Header("ヒットストップ風スロー設定")]
        [SerializeField]
        [Tooltip("ズーム中に時間をスローにするかどうか。")]
        private bool useTimeSlow = true;

        [SerializeField]
        [Tooltip("スロー中のTime.timeScale。0に近いほど停止に近づきます。1で通常速度。")]
        private float slowTimeScale = 0.2f;

        [SerializeField]
        [Tooltip("スロー状態を維持する実時間(秒)。Time.timeScaleに関係なく実際の経過時間で管理します。")]
        private float slowDurationRealTime = 0.08f;

        [Header("補間カーブ")]
        [SerializeField]
        [Tooltip("ズームイン/アウトの補間カーブ。未設定なら線形補間します。")]
        private AnimationCurve zoomCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);

        private Camera _camera;

        // ズーム前のカメラ状態を保存
        private Vector3 _originalLocalPosition;
        private float _originalFov;

        private Coroutine _zoomRoutine;
        private Coroutine _slowRoutine;

        private void Awake()
        {
            _camera = GetComponent<Camera>();
            _originalLocalPosition = transform.localPosition;
            _originalFov = _camera.fieldOfView;
        }

        /// <summary>
        /// ヒット時に呼び出すメインAPI。
        /// 指定したターゲット方向に一瞬だけズームインさせます。
        /// </summary>
        /// <param name="target">注目させたいTransform(敵やヒット位置のダミーなど)</param>
        public void PlayZoom(Transform target)
        {
            if (target == null)
            {
                Debug.LogWarning("[HitStopZoom] target が null です。ズームは実行されません。");
                return;
            }

            // 進行中のズームがあればリセットしてから開始
            if (_zoomRoutine != null)
            {
                StopCoroutine(_zoomRoutine);
                ResetCameraInstant();
            }

            _zoomRoutine = StartCoroutine(ZoomRoutine(target));

            // スロー演出も同時に開始(オプション)
            if (useTimeSlow)
            {
                if (_slowRoutine != null)
                {
                    StopCoroutine(_slowRoutine);
                    // Time.timeScale は最後のコルーチンで戻すので、ここでは触らない
                }
                _slowRoutine = StartCoroutine(TimeSlowRoutine());
            }
        }

        /// <summary>
        /// カメラのズームイン/アウトを行うコルーチン。
        /// </summary>
        private IEnumerator ZoomRoutine(Transform target)
        {
            // ズーム開始時点の状態を保存
            _originalLocalPosition = transform.localPosition;
            _originalFov = _camera.fieldOfView;

            // ターゲット方向へのオフセットを計算
            Vector3 toTarget = (target.position - transform.position).normalized;
            Vector3 zoomOffsetWorld = toTarget * zoomDistance;

            // ローカル座標に変換してから適用したい場合はこちら
            // Vector3 zoomOffsetLocal = transform.InverseTransformDirection(zoomOffsetWorld);

            Vector3 startPos = transform.position;
            Vector3 targetPos = startPos + zoomOffsetWorld;

            float startFov = _camera.fieldOfView;
            float targetFov = (zoomFov > 0f) ? zoomFov : startFov;

            // --- ズームイン ---
            if (zoomInDuration > 0f)
            {
                float t = 0f;
                while (t < zoomInDuration)
                {
                    t += Time.unscaledDeltaTime; // スロー中でも一定速度で補間するために unscaled を使用

                    float normalized = Mathf.Clamp01(t / zoomInDuration);
                    float curve = (zoomCurve != null) ? zoomCurve.Evaluate(normalized) : normalized;

                    // 位置補間(ワールド座標)
                    transform.position = Vector3.Lerp(startPos, targetPos, curve);

                    // FOV補間
                    _camera.fieldOfView = Mathf.Lerp(startFov, targetFov, curve);

                    yield return null;
                }
            }
            else
            {
                // ズームイン時間が0以下なら即時反映
                transform.position = targetPos;
                _camera.fieldOfView = targetFov;
            }

            // --- ズームアウト ---
            if (zoomOutDuration > 0f)
            {
                Vector3 outStartPos = transform.position;
                float outStartFov = _camera.fieldOfView;

                float t = 0f;
                while (t < zoomOutDuration)
                {
                    t += Time.unscaledDeltaTime;

                    float normalized = Mathf.Clamp01(t / zoomOutDuration);
                    float curve = (zoomCurve != null) ? zoomCurve.Evaluate(normalized) : normalized;

                    transform.position = Vector3.Lerp(outStartPos, _originalLocalPosition, curve);
                    _camera.fieldOfView = Mathf.Lerp(outStartFov, _originalFov, curve);

                    yield return null;
                }
            }

            // 最終的に元の状態へスナップ
            ResetCameraInstant();
            _zoomRoutine = null;
        }

        /// <summary>
        /// 時間を一時的にスローにするコルーチン。
        /// 実時間ベースで slowDurationRealTime のあいだだけスローにします。
        /// </summary>
        private IEnumerator TimeSlowRoutine()
        {
            float originalTimeScale = Time.timeScale;

            // すでに他のシステムがTime.timeScaleを変更している可能性もあるので、
            // ここでは「上書き」して、終了時に元の値へ戻します。
            Time.timeScale = slowTimeScale;
            Time.fixedDeltaTime = 0.02f * Time.timeScale; // 物理ステップも追従させる

            float elapsed = 0f;
            while (elapsed < slowDurationRealTime)
            {
                elapsed += Time.unscaledDeltaTime; // 実時間ベース
                yield return null;
            }

            // 元のTimeScaleへ戻す
            Time.timeScale = originalTimeScale;
            Time.fixedDeltaTime = 0.02f * Time.timeScale;

            _slowRoutine = null;
        }

        /// <summary>
        /// カメラの位置とFOVを、記録しておいた元の状態へ即座に戻します。
        /// </summary>
        private void ResetCameraInstant()
        {
            // 位置はローカル座標を基準に戻す
            transform.localPosition = _originalLocalPosition;
            _camera.fieldOfView = _originalFov;
        }

        /// <summary>
        /// デバッグ用:インスペクタからテスト発火できるようにする。
        /// </summary>
        [ContextMenu("Test Zoom (Self as Target)")]
        private void TestZoomSelf()
        {
            PlayZoom(transform);
        }
    }
}

使い方の手順

ここからは、実際にプレイヤーや敵の攻撃に組み込む手順を見ていきましょう。

手順①:カメラに HitStopZoom をアタッチする

  1. シーン内のメインカメラ(Main Camera)を選択
  2. Add Component ボタンから HitStopZoom を追加
  3. インスペクタで以下のパラメータを調整
    • Zoom Distance: 1.5 ~ 3.0 くらいから試す
    • Zoom Fov: 通常が60なら、40~50あたりが無難
    • Zoom In Duration: 0.03 ~ 0.07 くらい(短いとキレが出る)
    • Zoom Out Duration: 0.1 ~ 0.2 くらい(余韻を持たせる)
    • Use Time Slow: ONにするとヒットストップ感UP

手順②:攻撃スクリプトから PlayZoom を呼ぶ

プレイヤーの近接攻撃を例にします。
「敵に当たった瞬間」に HitStopZoom.PlayZoom() を呼び出すだけです。


using UnityEngine;

public class PlayerAttack : MonoBehaviour
{
    [SerializeField]
    private float attackRange = 1.5f;

    [SerializeField]
    private LayerMask enemyLayer;

    [SerializeField]
    private Transform attackOrigin;

    private Camera _mainCamera;
    private Samples.CameraEffects.HitStopZoom _hitStopZoom;

    private void Start()
    {
        _mainCamera = Camera.main;
        if (_mainCamera != null)
        {
            _hitStopZoom = _mainCamera.GetComponent<Samples.CameraEffects.HitStopZoom>();
        }
    }

    private void Update()
    {
        // 簡易的にマウス左クリックで攻撃
        if (Input.GetMouseButtonDown(0))
        {
            TryAttack();
        }
    }

    private void TryAttack()
    {
        // 攻撃の当たり判定(シンプルなSphereCast例)
        Vector3 origin = attackOrigin.position;
        Vector3 direction = attackOrigin.forward;

        if (Physics.SphereCast(origin, 0.3f, direction, out RaycastHit hit, attackRange, enemyLayer))
        {
            // 敵にダメージを与える処理(省略)
            // hit.collider.GetComponent<Enemy>()?.TakeDamage(1);

            // カメラにズーム演出を依頼
            if (_hitStopZoom != null)
            {
                // 敵の中心に向かってズームしたいので、敵のTransformを渡す
                _hitStopZoom.PlayZoom(hit.collider.transform);
            }
        }
    }

    private void OnDrawGizmosSelected()
    {
        if (attackOrigin == null) return;

        Gizmos.color = Color.red;
        Gizmos.DrawLine(attackOrigin.position, attackOrigin.position + attackOrigin.forward * attackRange);
    }
}

ポイントは、プレイヤー側は「ヒットしたかどうか」だけを知っていればよく、カメラの補間ロジックは一切書いていないことです。
「責務の分離」ができていると、カメラ演出を変えたいときに HitStopZoom だけを差し替えられます。

手順③:敵や動く床など、別オブジェクトでも再利用する

たとえば「トゲ床に乗ったときにカメラをズームさせる」「巨大なボスの攻撃が当たったときだけズームを強めにする」といった演出も、同じコンポーネントを使い回せます。

敵の攻撃スクリプト側で:


using UnityEngine;

public class EnemyAttack : MonoBehaviour
{
    [SerializeField]
    private Transform hitEffectPoint;

    private Samples.CameraEffects.HitStopZoom _hitStopZoom;

    private void Start()
    {
        Camera mainCam = Camera.main;
        if (mainCam != null)
        {
            _hitStopZoom = mainCam.GetComponent<Samples.CameraEffects.HitStopZoom>();
        }
    }

    // プレイヤーに当たったときに呼ばれる想定
    public void OnHitPlayer()
    {
        if (_hitStopZoom != null && hitEffectPoint != null)
        {
            // ボスの攻撃なので、プレイヤーではなく攻撃の着弾地点をターゲットにする
            _hitStopZoom.PlayZoom(hitEffectPoint);
        }
    }
}

このように、「ヒットしたらターゲットTransformを渡すだけ」というインターフェイスにしておくと、プレイヤー・敵・ギミック問わず簡単に使い回せます。

手順④:パラメータを変えてゲーム全体の手触りを調整する

  • 通常攻撃:Zoom Distance = 1.5, Zoom Fov = 45, Use Time Slow = ON
  • 必殺技:Zoom Distance = 3.0, Zoom Fov = 35, Slow Duration = 0.15 など強めに
  • 環境ギミック(トゲ床など):Use Time Slow = OFF でカメラだけ軽く寄る

同じ演出コンポーネントでも、プレハブごとにパラメータを変えるだけで「強弱」を演出できるのがコンポーネント指向の良いところですね。


メリットと応用

メリット①:カメラ演出を完全にカプセル化できる

HitStopZoom は、

  • カメラの位置補間
  • FOVの補間
  • Time.timeScale の一時変更

といった「演出としてのカメラ制御」をすべて自分の中で完結させています。
攻撃スクリプト側は「ヒットした・ターゲットはこれ」の2つだけを知っていればOKなので、

  • プレイヤーコードが肥大化しない
  • 別のシーン・別のプロジェクトでも簡単に移植できる
  • 演出担当の人がカメラコンポーネントだけを触って調整できる

といったメリットがあります。

メリット②:プレハブ単位で演出を差し替えやすい

たとえば、

  • 「通常ステージ用カメラ」プレハブ:Use Time Slow = ON
  • 「タイムアタック用カメラ」プレハブ:Use Time Slow = OFF

といったように、同じゲームロジックでも、カメラプレハブを差し替えるだけでゲームの手触りを変えられます
レベルデザイン時にも、シーンごとにカメラの演出テイストを変えたいときに便利ですね。

メリット③:他のカメラコンポーネントとも共存しやすい

カメラの追従やシェイクなどを別コンポーネントで実装していても、HitStopZoom は「一時的に位置/FOVを上書きしてすぐ戻す」だけなので、比較的共存しやすい構造になっています。
さらに分離したい場合は、Cinemachine や独自のカメラマネージャーと連携するラッパーを用意するのも良いですね。


改造案:ズーム完了時にコールバックを受け取る

「ズーム演出が終わったら、必殺技の次のフェーズに進めたい」といったケースでは、
簡単なイベントコールバックを追加しておくと便利です。

例えば、こんなメソッドを HitStopZoom に追加して、ズーム終了時に呼んであげると拡張性が上がります。


public System.Action OnZoomFinished;

private void InvokeZoomFinished()
{
    // 登録されているリスナーがあれば呼び出す
    OnZoomFinished?.Invoke();
}

そして ZoomRoutine の最後(ResetCameraInstant(); の直後など)に InvokeZoomFinished(); を差し込めば、
外部から


_hitStopZoom.OnZoomFinished += () =>
{
    // 必殺技の次のフェーズへ進む処理
};

といった形で、演出の「つなぎ」を綺麗に構成できます。

このように、小さな責務のコンポーネントにしておくと、
後から「もう一歩だけ欲しい機能」を安全に積み増しできるのが嬉しいですね。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!