【ゲーム制作】ヒットストップとは?|TimeScaleで「一瞬の間」を作る演出

hitstop thumbnail ゲーム制作

攻撃がヒットした瞬間だけ、画面が一瞬止まる。あの演出が、ヒットストップです。スローモーションと何が違うのか、TimeScaleは結局、何に掛けているのか。実際に作ってみると、意外な落とし穴があります。

この記事では、アクションゲームの「効いてる感」を作る代表的なテクニック、ヒットストップについて、実際にコンパイル・実行した結果を交えながら、仕組みと、実装するときに踏みやすい罠を紹介します。

ヒットストップとは?

ヒットストップとは、

・攻撃がヒットした瞬間、ゲーム全体の進行speedを一時的にほぼゼロ(スローではなくほぼ静止)まで落とすことで、「今、確かに攻撃が当たった」という手応えを演出するテクニック

です。格闘ゲームやアクションゲームで、攻撃がヒットした瞬間に画面がカクッと止まる、あの感覚がそれにあたります。

キーになるのはTimeScale(タイムスケール)という考え方です。プレイヤーの移動や敵のアニメーションなど、ゲーム内のあらゆる動きの計算に「1.0が通常速度」というスケール値を掛けておき、この値を一時的に0.05のような小さな値に落とすことで「ほぼ止まっているけど完全には止まっていない」独特の間を作ります。

実装してみる

最小構成のHitStopクラスと、TimeScaleの影響を受けて動くPlayerを実際に書いてコンパイル・実行してみます。

// ゲーム全体の「時間の流れる速さ」。1.0が通常速度。
float g_TimeScale = 1.0f;

class HitStop {
public:
    static void Trigger(float duration, float scale = 0.05f) {
        s_Timer = duration;
        s_OriginalScale = g_TimeScale; // 発動前の速度を覚えておく
        g_TimeScale = scale;           // ゲーム全体をスローに
    }

    // 毎フレーム呼ぶ(残り時間管理)。ここは「実時間」で減らすのがポイント。
    static void Update(float realDeltaTime) {
        if (s_Timer <= 0.0f) return;
        s_Timer -= realDeltaTime;
        if (s_Timer <= 0.0f) {
            g_TimeScale = s_OriginalScale; // 元の速度に戻す
        }
    }

    static bool IsActive() { return s_Timer > 0.0f; }

private:
    static float s_Timer;
    static float s_OriginalScale;
};
float HitStop::s_Timer = 0.0f;
float HitStop::s_OriginalScale = 1.0f;

struct Player {
    float x = 0.0f;
    float speed = 6.0f; // 1秒あたり6進む

    void Update(float realDeltaTime) {
        // ゲーム内の移動処理は、必ずTimeScaleを掛けて更新する
        x += speed * g_TimeScale * realDeltaTime;
    }
};

5フレーム目に「攻撃がヒットした」想定でヒットストップを発動させ、固定60FPSで30フレーム分回してみます。

frame  0: TimeScale=1.00  IsActive=0  player.x=0.100
frame  1: TimeScale=1.00  IsActive=0  player.x=0.200
frame  2: TimeScale=1.00  IsActive=0  player.x=0.300
frame  3: TimeScale=1.00  IsActive=0  player.x=0.400
frame  4: TimeScale=1.00  IsActive=0  player.x=0.500
--- frame  5: HitStop::Trigger(0.15f, 0.05f) ---
frame  5: TimeScale=0.05  IsActive=1  player.x=0.505
frame  6: TimeScale=0.05  IsActive=1  player.x=0.510
frame  7: TimeScale=0.05  IsActive=1  player.x=0.515
frame  8: TimeScale=0.05  IsActive=1  player.x=0.520
frame  9: TimeScale=0.05  IsActive=1  player.x=0.525
frame 10: TimeScale=0.05  IsActive=1  player.x=0.530
frame 11: TimeScale=0.05  IsActive=1  player.x=0.535
frame 12: TimeScale=0.05  IsActive=1  player.x=0.540
frame 13: TimeScale=0.05  IsActive=1  player.x=0.545
frame 14: TimeScale=1.00  IsActive=0  player.x=0.550
frame 15: TimeScale=1.00  IsActive=0  player.x=0.650

ヒットストップ発動前は1フレームでplayer.xが0.1ずつ進んでいるのに対し、発動中(frame 5〜13)は0.005ずつしか進んでいません。TimeScaleが0.05になったことで、動きがほぼ止まって見える状態になっているのが分かります。そしてs_Timerが尽きたframe 14で、ちゃんと通常速度(0.1ずつ)に戻っています。

ポイントは、HitStop::Updateの残り時間(s_Timer)は実時間(unscaled)で減らすことです。もしここもTimeScaleを掛けて減らしてしまうと、TimeScaleが0.05のときはs_Timerもほぼ減らなくなり、いつまで経ってもヒットストップが終わらなくなってしまいます。

【重要】実際に踏んだ罠:連続ヒットで解除できなくなる

このコードで実際にデモを組んでいて、地味に怖いバグを踏みました。1段目のヒットストップがまだ終わっていないうちに、2段目の攻撃がヒットしてもう一度Triggerを呼んでしまうケースです。

// 3フレーム目: 1段目の攻撃がヒット
if (frame == 3) {
    HitStop::Trigger(0.3f, 0.05f);
}
// 8フレーム目: まだ1段目のヒットストップ中なのに、2段目がヒット
if (frame == 8) {
    HitStop::Trigger(0.1f, 0.05f);
}

実行結果です。

--- frame  3: 1段目ヒット Trigger(0.3f, 0.05f) ---
frame  3: TimeScale=0.050 IsActive=1
--- frame  8: 2段目ヒット Trigger(0.1f, 0.05f) (まだ1段目の最中!) ---
frame  8: TimeScale=0.050 IsActive=1
  ...
frame 13: TimeScale=0.050 IsActive=1
frame 14: TimeScale=0.050 IsActive=0
frame 15: TimeScale=0.050 IsActive=0
frame 16: TimeScale=0.050 IsActive=0
frame 17: TimeScale=0.050 IsActive=0

frame 14で「IsActive=0」つまりヒットストップは終わっているはずなのに、TimeScaleが1.0に戻らず0.050のまま固まってしまいました。ゲームがずっとスローモーションのままになる、実プレイでは致命的なバグです。

原因は、2段目のTriggerが呼ばれた時点(frame 8)で、s_OriginalScale = g_TimeScaleによって「元の速度」として保存される値が、既にヒットストップで落ちた後の0.05になってしまうことです。1段目の本当の元の速度(1.0)は、2段目のTriggerの時点で上書きされて失われてしまいます。

ヒットストップのよくある失敗例と対処法

①連続ヒットでTimeScaleが戻らなくなる

上で実演した通りです。対処法はいくつかありますが、シンプルなのはs_OriginalScaleには常に固定値の1.0fを使うことです。「TimeScaleは通常時は必ず1.0」という前提を守れるなら、わざわざ発動前の値を保存する必要はありません。

②残り時間をscaled deltaTimeで減らしてしまう

HitStop::Updateの中で、うっかりrealDeltaTimeの代わりにTimeScale適用後のdeltaTimeを渡すと、TimeScaleが小さいときほどs_Timerの減りも遅くなり、ヒットストップがヒットストップ自身の効果でなかなか終わらなくなるという自己矛盾が起きます。ヒットストップの管理自体は必ず実時間で行いましょう。

③IsActive()を見ずに毎フレームTriggerしてしまう

連続ヒットする攻撃(多段ヒットの技など)で、ヒット判定のたびに無条件でTriggerを呼ぶと①の罠を踏みやすくなります。多段ヒットでは「最後のヒットだけ長めに止める」「既にヒットストップ中なら残り時間を延長するだけにする」など、意図的な設計が必要です。

注意点

  • ヒットストップの残り時間は必ず実時間(unscaled)で減らす
  • 「発動前の速度」を保存する設計は、連続発動で壊れることがある
  • 移動やアニメーションなど、影響を受けるべき処理には必ずTimeScaleを掛ける(掛け忘れると一部だけ止まらない)
  • UIなど止めたくない処理には掛けない設計にする(今回のデモには含めていないが実装時は要注意)

まとめ

  • ヒットストップはTimeScaleを一時的に落として「一瞬の間」を作る演出
  • 残り時間の管理は実時間で行う(そうしないと自己矛盾で終わらなくなる)
  • 連続発動すると「発動前の速度」を上書きしてしまい、TimeScaleが戻らなくなる罠がある(実際に踏んだ)
  • 対処法はシンプルで、戻す先を固定の1.0fにするだけでも十分なケースが多い

残り時間の管理は、実時間で行います。ゲーム内の時間で数えると、TimeScaleが小さくなったとき、自己矛盾で終わらなくなるからです。連続発動すると、「発動前の速度」を上書きして、TimeScaleが戻らなくなる罠もあります(実際に踏みました)。戻す先を、固定の1.0fにするだけでも、十分なケースが多いです。

関連記事