Unityを触り始めた頃は、つい「とりあえず全部Updateに書く」実装をしてしまいがちですよね。
攻撃判定、入力処理、アニメーション制御、カメラ揺れ、ヒットストップ……すべてが1つのスクリプト・1つのUpdateに詰め込まれていくと、少しずつ「手を入れるたびに壊れる怖いクラス」になっていきます。

とくにヒットストップ(Time.timeScaleを一瞬だけ止める演出)を直接そこに書いてしまうと、

  • どこからでも Time.timeScale = 0f;1f; が呼ばれてバグの温床になる
  • 複数の攻撃・演出が同時にヒットストップを使うと、復帰タイミングが破綻する
  • テストのたびに値を探して直すのが面倒

といった問題が出てきます。

そこでこの記事では、「ヒットストップだけを責務に持つ」小さなコンポーネント TimeFreeze を用意して、
攻撃やエフェクト側からは RequestFreeze() を呼ぶだけで済むようにしてみましょう。

【Unity】一撃の重みを演出するヒットストップ!「TimeFreeze」コンポーネント

フルコード


using System.Collections;
using UnityEngine;

/// <summary>
/// 攻撃ヒット時などに、ゲーム全体を一瞬だけスローモーション(=ヒットストップ)にするコンポーネント。
/// 
/// ・Time.timeScale を一時的に変更して「重み」を演出する
/// ・複数の攻撃から同時に呼ばれても安全に扱えるよう、リクエストをキューとして管理
/// ・「ゲーム内時間(スケールされた時間)」ではなく「実時間(Realtime)」で制御するので、
///   ヒットストップ中でも正しく時間経過を測れる
/// 
/// 使い方の例:
/// ・プレイヤーの強攻撃がヒットしたときに RequestFreeze() を呼ぶ
/// ・敵の必殺技ヒット時に、より長めのヒットストップを RequestFreeze(0.15f) で指定
/// </summary>
public class TimeFreeze : MonoBehaviour
{
    // ==============================
    // 設定パラメータ
    // ==============================

    [Header("デフォルトのヒットストップ設定")]

    [SerializeField]
    [Tooltip("ヒットストップの長さ(秒)。強攻撃などで使う標準値。")]
    private float defaultFreezeDuration = 0.05f;

    [SerializeField]
    [Tooltip("ヒットストップ中に設定する Time.timeScale の値。0 に近いほど完全停止に近くなる。")]
    private float freezeTimeScale = 0.0f;

    [Header("セーフティ設定")]

    [SerializeField]
    [Tooltip("ヒットストップが連続で何秒以上続いたら強制的に解除するか(実時間ベース)。")]
    private float maxContinuousFreezeTime = 1.0f;

    // ==============================
    // 内部状態
    // ==============================

    /// <summary>
    /// ヒットストップ前の timeScale を保存しておく。
    /// 途中で他のスクリプトが timeScale を変更しても、復帰時は「開始時の値」に戻す。
    /// </summary>
    private float _originalTimeScale = 1.0f;

    /// <summary>
    /// 現在ヒットストップ中かどうか。
    /// </summary>
    private bool _isFreezing = false;

    /// <summary>
    /// 現在進行中のヒットストップを管理しているコルーチン。
    /// 新しいリクエストが来たら止めて作り直す。
    /// </summary>
    private Coroutine _freezeCoroutine = null;

    /// <summary>
    /// 現在までに累積したヒットストップ時間(実時間ベース)。
    /// セーフティ用。
    /// </summary>
    private float _accumulatedFreezeTime = 0f;

    // ==============================
    // 公開API
    // ==============================

    /// <summary>
    /// デフォルト設定でヒットストップを要求する。
    /// 典型的には「強攻撃がヒットしたとき」に呼び出す。
    /// 
    /// 例:
    ///     // 攻撃判定スクリプトから
    ///     timeFreeze.RequestFreeze();
    /// </summary>
    public void RequestFreeze()
    {
        RequestFreeze(defaultFreezeDuration);
    }

    /// <summary>
    /// 任意の長さでヒットストップを要求する。
    /// 
    /// 例:
    ///     // 必殺技は長めに
    ///     timeFreeze.RequestFreeze(0.15f);
    /// </summary>
    /// <param name="duration">ヒットストップの長さ(秒)。Realtimeベース。>
    public void RequestFreeze(float duration)
    {
        if (duration <= 0f)
        {
            // 0以下なら何もしない
            return;
        }

        // すでにヒットストップ中であれば、残り時間を延長するイメージで再スタートする
        if (_freezeCoroutine != null)
        {
            StopCoroutine(_freezeCoroutine);
        }

        _freezeCoroutine = StartCoroutine(FreezeCoroutine(duration));
    }

    /// <summary>
    /// 外部から強制的にヒットストップを解除したい場合に呼ぶ。
    /// 通常は不要だが、タイトルに戻る処理などで安全のために呼ぶのはアリ。
    /// </summary>
    public void ForceStopFreeze()
    {
        if (_freezeCoroutine != null)
        {
            StopCoroutine(_freezeCoroutine);
            _freezeCoroutine = null;
        }

        RestoreTimeScale();
        _isFreezing = false;
        _accumulatedFreezeTime = 0f;
    }

    // ==============================
    // コルーチン本体
    // ==============================

    /// <summary>
    /// 実時間ベースでヒットストップを行うコルーチン。
    /// WaitForSecondsRealtime を使うことで、timeScale に影響されずに時間を測る。
    /// </summary>
    private IEnumerator FreezeCoroutine(float duration)
    {
        // ヒットストップ開始時に timeScale を保存しておく
        if (!_isFreezing)
        {
            _originalTimeScale = Time.timeScale;
            _accumulatedFreezeTime = 0f;
        }

        _isFreezing = true;

        // 実際に timeScale を変更
        Time.timeScale = freezeTimeScale;

        float startRealtime = Time.realtimeSinceStartup;
        float endRealtime = startRealtime + duration;

        while (Time.realtimeSinceStartup < endRealtime)
        {
            // セーフティ: 累積時間を計測し、長すぎる場合は強制解除
            float now = Time.realtimeSinceStartup;
            _accumulatedFreezeTime += Time.unscaledDeltaTime;

            if (_accumulatedFreezeTime >= maxContinuousFreezeTime)
            {
                Debug.LogWarning(
                    $"[TimeFreeze] ヒットストップが {maxContinuousFreezeTime} 秒を超えたため、強制解除しました。設定を見直してください。");
                break;
            }

            // 1フレーム待機 (Realtimeベース)
            yield return null;
        }

        // ヒットストップ終了
        RestoreTimeScale();
        _isFreezing = false;
        _freezeCoroutine = null;
        _accumulatedFreezeTime = 0f;
    }

    // ==============================
    // ヘルパー
    // ==============================

    /// <summary>
    /// Time.timeScale を元の値に戻す。
    /// 他のスクリプトで timeScale をいじっていた場合でも、
    /// 「ヒットストップ開始時の値」に戻す形になる。
    /// </summary>
    private void RestoreTimeScale()
    {
        Time.timeScale = _originalTimeScale;
    }

    // ==============================
    // デバッグ用 (任意で有効化)
    // ==============================

#if UNITY_EDITOR
    private void Update()
    {
        // エディタ上で簡単にテストできるように、
        // キーボード入力でヒットストップを発動させるサンプル。
        // 実際のゲームでは削除してOK。
        if (Input.GetKeyDown(KeyCode.F))
        {
            Debug.Log("[TimeFreeze] Fキーでテストヒットストップ発動");
            RequestFreeze();
        }
    }
#endif
}

使い方の手順

  1. TimeFreezeコンポーネントを用意する
    上のコードを TimeFreeze.cs という名前で保存し、Unityのプロジェクトに追加します。
    空のGameObjectを作成し、名前を TimeFreezeController などにして、このコンポーネントをアタッチしましょう。
    シーンに1つあればOKです。
  2. 強攻撃スクリプトから参照する
    例として、プレイヤーの強攻撃が敵にヒットしたときにヒットストップを入れたい場合を考えます。
    プレイヤーの攻撃判定スクリプトに、以下のようなコードを追加します。
    
    using UnityEngine;
    
    public class PlayerStrongAttack : MonoBehaviour
    {
        [SerializeField]
        private TimeFreeze timeFreeze; // シーン上の TimeFreezeController をアサイン
    
        [SerializeField]
        private float damage = 10f;
    
        private void OnTriggerEnter(Collider other)
        {
            // 例: "Enemy" タグのオブジェクトに当たったらヒットとみなす
            if (!other.CompareTag("Enemy")) return;
    
            // ダメージ処理など
            var health = other.GetComponent<EnemyHealth>();
            if (health != null)
            {
                health.TakeDamage(damage);
            }
    
            // 強攻撃ヒット時にヒットストップを発動
            if (timeFreeze != null)
            {
                // デフォルト値(0.05秒)でヒットストップ
                timeFreeze.RequestFreeze();
            }
        }
    }
        
    • timeFreeze には、シーン上の TimeFreezeController オブジェクトをドラッグ&ドロップでアサインします。
    • これで、強攻撃が敵に当たるたびにゲーム全体が一瞬止まり、重みを演出できます。
  3. 敵の大技や動く床にも簡単に流用する
    たとえば敵の必殺技がプレイヤーに直撃したとき、少し長めのヒットストップを入れたい場合:
    
    using UnityEngine;
    
    public class BossSmashAttack : MonoBehaviour
    {
        [SerializeField]
        private TimeFreeze timeFreeze;
    
        [SerializeField]
        private float smashDamage = 30f;
    
        [SerializeField]
        private float smashHitStopDuration = 0.12f;
    
        private void OnTriggerEnter(Collider other)
        {
            if (!other.CompareTag("Player")) return;
    
            var playerHealth = other.GetComponent<PlayerHealth>();
            if (playerHealth != null)
            {
                playerHealth.TakeDamage(smashDamage);
            }
    
            // ボスの一撃は長めのヒットストップ
            if (timeFreeze != null)
            {
                timeFreeze.RequestFreeze(smashHitStopDuration);
            }
        }
    }
        

    同じ TimeFreeze コンポーネントを使い回すだけで、プレイヤー・敵・ギミック(動く床が潰す瞬間など)のどこからでも統一的にヒットストップをかけられます。

  4. パラメータを調整して「気持ちよさ」を追い込む
    • defaultFreezeDuration … 強攻撃の標準ヒットストップ時間 (0.03〜0.07秒くらいが目安)
    • freezeTimeScale … 完全停止なら 0.0、ややスローにしたければ 0.05〜0.1 など
    • maxContinuousFreezeTime … バグで止まりっぱなしにならないようにする保険

    シーンを再生しながら、強攻撃・必殺技・敵の攻撃などを試しつつ値をいじると、
    「気持ちよくヒットする」ポイントが見つかりやすいです。

メリットと応用

TimeFreezeコンポーネントを1つにまとめることで、次のようなメリットがあります。

  • ヒットストップのロジックが1箇所に集約され、巨大なGodクラスに混ざらない
  • プレイヤー / 敵 / ギミックなど、どこからでも RequestFreeze() だけで共通の演出を呼べる
  • ヒットストップの長さや強さを1つのプレハブ/オブジェクトのインスペクタから調整できる
  • Time.timeScale をいじる処理が一元管理されるため、他システムとの競合バグが減る

レベルデザイン的にも、「この攻撃は0.05秒、この必殺は0.12秒」といった調整を、
各攻撃プレハブ単位で数値を持たせるだけで済むので、プレハブ管理がかなり楽になります。

さらに、TimeFreeze に少しだけ手を入れて、ヒットストップ開始・終了イベントを飛ばすようにすると、
画面エフェクトやカメラ演出と連動させることもできます。

例えば、ヒットストップ開始時に画面を少し暗くする・終了時に戻す、といった改造案はこんな感じです。


/// <summary>
/// ヒットストップ開始時に画面を少し暗くし、終了時に戻すサンプル。
/// PostProcessing や UI イメージのアルファをいじるなど、用途に合わせて中身を差し替えましょう。
/// </summary>
private void OnFreezeVisual(bool isStart)
{
    // ここでは簡単のため、ログだけ出す。
    // 実際には、カメラシェイクや画面フラッシュなどを呼び出すと良い。
    if (isStart)
    {
        Debug.Log("[TimeFreeze] ヒットストップ開始: 画面演出をONにする処理をここに書く");
    }
    else
    {
        Debug.Log("[TimeFreeze] ヒットストップ終了: 画面演出をOFFにする処理をここに書く");
    }
}

このように、「ヒットストップ」という1つの責務に絞ったコンポーネントにしておくと、
後から演出を盛りたくなったときにも、TimeFreezeだけを触ればよくなります。
巨大なUpdateに書き足していくスタイルから卒業して、小さなコンポーネントを積み上げる設計を目指していきましょう。