【Cocos Creator 3.8】ImpactShake(ヒットストップ)の実装:アタッチするだけで攻撃ヒット時に一瞬だけゲーム全体をスロー再生して打撃感を出す汎用スクリプト

攻撃が命中した瞬間に、ゲーム全体の時間を一瞬だけスローにする「ヒットストップ」は、アクションゲームの手触りを大きく向上させます。この記事では、どのノードにアタッチしても使える汎用コンポーネント ImpactShake を TypeScript で実装し、インスペクタから簡単に調整できるようにします。

外部の GameManager やシングルトンには一切依存せず、このコンポーネント単体で完結します。攻撃ヒット時にメソッドを呼ぶだけで、Engine.timeScale を一瞬だけ下げて強烈な打撃感を演出できます。


コンポーネントの設計方針

実現したい機能

  • 攻撃ヒットなどのタイミングで 任意に呼び出せる ヒットストップ機能
  • Engine.timeScale を一時的に下げ、一定時間後に自動で元の値に戻す
  • 複数回連続で呼ばれても破綻しない(前のヒットストップ中に再度ヒットしても安全)
  • ヒットストップの強さ(スロー倍率)長さ(時間)をインスペクタから調整可能
  • ゲーム全体の時間スケールを変えるため、どのノードにアタッチしても動作する(シーンに1つあれば十分)

外部依存をなくすためのアプローチ

  • Engine.timeScale をこのコンポーネント内で直接制御する
  • ヒット時のトリガーは public メソッド triggerHitStop() として公開し、攻撃判定側から直接呼び出せるようにする
  • 内部で コルーチン的な処理this.scheduleOnce() で実装し、一定時間後に自動で元の timeScale を復元する
  • 他のカスタムスクリプト(GameManager など)には一切依存しない

インスペクタで設定可能なプロパティ

ImpactShake コンポーネントで調整できるプロパティを以下のように設計します。

  • enabledOnStart: boolean
    • 初期値: true
    • 役割: シーン開始時にヒットストップ機能を有効にするかどうか。無効にすると triggerHitStop() を呼んでも何も起きない。
  • hitStopScale: number
    • 初期値: 0.1
    • 役割: ヒットストップ中の Engine.timeScale
      例: 0.1 なら 10% の速度(かなりスロー)、0.3 なら 30% の速度。
    • 制約: 0.01 ~ 1.0 の範囲に制限(1.0 以上だとスローにならないため)。
  • hitStopDuration: number
    • 初期値: 0.08
    • 役割: ヒットストップの持続時間(リアルタイム秒)。短いほど「カクッ」とした瞬間停止感、長いほど「グッ」と止まる感じ。
    • 制約: 0.01 ~ 0.5 秒程度に制限。
  • maxStackDuration: number
    • 初期値: 0.2
    • 役割: ヒットストップが 連続で発生したときに、合計で何秒まで延長してよいか の上限。
    • 例: 0.2 にしておくと、連続ヒットで何度も triggerHitStop() が呼ばれても、ヒットストップの総時間は最大 0.2 秒までに制限される。
  • restoreTimeScale: number
    • 初期値: 1.0
    • 役割: ヒットストップ終了後に復元する Engine.timeScale の値。通常は 1.0。
    • 備考: スローモーション演出中のゲームに組み込む場合、ここを 0.5 などにしておくと、ヒットストップ後も 0.5 のままにできる。
  • useUnscaledTime: boolean
    • 初期値: true
    • 役割: ヒットストップの経過時間計測に timeScale の影響を受けない時間 を使うかどうか。
    • 解説:
      • true: どれだけ timeScale を下げても、ヒットストップは「リアルタイム」で指定秒数だけ続く。
      • false: timeScale の影響を受けるため、極端に小さい timeScale だとヒットストップが長く感じられる。
  • debugLog: boolean
    • 初期値: false
    • 役割: ヒットストップ開始・終了時にログを出すかどうか。開発中の確認に便利。

TypeScriptコードの実装

以下が完成した ImpactShake.ts の全コードです。


import { _decorator, Component, Engine, game, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * ImpactShake
 * 
 * 攻撃ヒット時などに triggerHitStop() を呼び出すことで、
 * 一時的に Engine.timeScale を下げて打撃感を演出する汎用コンポーネント。
 * 
 * - 外部の GameManager などには一切依存しません。
 * - シーン内の任意のノードに 1 つアタッチしておけば使用可能です。
 */
@ccclass('ImpactShake')
export class ImpactShake extends Component {

    @property({
        tooltip: 'シーン開始時にヒットストップ機能を有効にするかどうか。\nfalse の場合 triggerHitStop() を呼んでも何も起きません。',
    })
    public enabledOnStart: boolean = true;

    @property({
        tooltip: 'ヒットストップ中の Engine.timeScale。\n0.01 ~ 1.0 の範囲で設定してください。\n小さいほど強いスローになります。',
        min: 0.01,
        max: 1.0,
        step: 0.01,
    })
    public hitStopScale: number = 0.1;

    @property({
        tooltip: '1 回のヒットストップの長さ(秒・リアルタイム基準)。\n0.01 ~ 0.5 秒程度が目安です。',
        min: 0.01,
        max: 0.5,
        step: 0.01,
    })
    public hitStopDuration: number = 0.08;

    @property({
        tooltip: '連続ヒット時にヒットストップ時間をどこまで延長してよいかの上限(秒)。\n0.0 にすると常に上書き方式になります。',
        min: 0.0,
        max: 1.0,
        step: 0.01,
    })
    public maxStackDuration: number = 0.2;

    @property({
        tooltip: 'ヒットストップ終了後に復元する Engine.timeScale の値。\n通常は 1.0 です。',
        min: 0.01,
        max: 4.0,
        step: 0.01,
    })
    public restoreTimeScale: number = 1.0;

    @property({
        tooltip: 'true: timeScale に影響されない時間でヒットストップの長さを管理します。\nfalse: timeScale の影響を受けるため、強いスローほど長く感じられます。',
    })
    public useUnscaledTime: boolean = true;

    @property({
        tooltip: 'ヒットストップ開始・終了時のログを出力するかどうか(デバッグ用)。',
    })
    public debugLog: boolean = false;

    // 内部状態管理用
    private _isActive: boolean = false;
    private _remainingDuration: number = 0;
    private _lastRealTime: number = 0;
    private _originalTimeScale: number = 1.0;

    onLoad() {
        // 現在の timeScale を記録しておく(通常は 1.0)。
        this._originalTimeScale = Engine.instance.timeScale;

        if (this.debugLog) {
            console.log('[ImpactShake] onLoad: original timeScale =', this._originalTimeScale);
        }
    }

    start() {
        // enabledOnStart が false の場合は何もしない。
        if (!this.enabledOnStart) {
            if (this.debugLog) {
                console.log('[ImpactShake] start: disabled on start.');
            }
            return;
        }

        // 初期化として、現在のリアルタイムを記録。
        this._lastRealTime = this._getRealTime();
    }

    update(deltaTime: number) {
        // ヒットストップ中でなければ何もしない。
        if (!this._isActive) {
            return;
        }

        // 経過時間を算出。
        const elapsed = this.useUnscaledTime
            ? this._getRealDeltaTime()
            : deltaTime; // deltaTime は timeScale の影響を受ける

        this._remainingDuration -= elapsed;

        // ヒットストップ終了判定
        if (this._remainingDuration <= 0) {
            this._endHitStop();
        }
    }

    /**
     * 攻撃ヒット時などに呼び出すことで、ヒットストップを開始(または延長)します。
     * 
     * @param duration 上書きしたいヒットストップ時間(省略時は hitStopDuration を使用)
     * @param scale    上書きしたい timeScale(省略時は hitStopScale を使用)
     */
    public triggerHitStop(duration?: number, scale?: number): void {
        if (!this.enabledOnStart) {
            // 機能が無効なら何もしない
            return;
        }

        const d = duration ?? this.hitStopDuration;
        const s = scale ?? this.hitStopScale;

        // 不正値の防御的チェック
        const clampedDuration = Math.max(0.0, d);
        const clampedScale = this._clamp(s, 0.01, 1.0);

        if (clampedDuration <= 0) {
            if (this.debugLog) {
                console.warn('[ImpactShake] triggerHitStop: duration is 0 or negative, ignored.');
            }
            return;
        }

        // すでにヒットストップ中の場合は、残り時間を延長するか上書きする。
        if (this._isActive) {
            const newDuration = this._remainingDuration + clampedDuration;

            if (this.maxStackDuration > 0) {
                // 上限付きでスタック
                this._remainingDuration = Math.min(newDuration, this.maxStackDuration);
            } else {
                // 常に上書き
                this._remainingDuration = clampedDuration;
            }

            // 強いスローが要求された場合は、より小さい timeScale を採用する。
            const currentScale = Engine.instance.timeScale;
            const nextScale = Math.min(currentScale, clampedScale);

            if (this.debugLog) {
                console.log('[ImpactShake] triggerHitStop: extend. remaining =', this._remainingDuration.toFixed(3), 'scale =', nextScale);
            }

            Engine.instance.timeScale = nextScale;
            return;
        }

        // 初回開始
        this._startHitStop(clampedDuration, clampedScale);
    }

    /**
     * 外部から明示的にヒットストップを解除したい場合に呼び出します。
     * 通常は自動的に解除されるため、使わなくても構いません。
     */
    public forceEndHitStop(): void {
        if (!this._isActive) {
            return;
        }
        this._endHitStop();
    }

    // --- 内部処理 ---

    private _startHitStop(duration: number, scale: number): void {
        // 現在の timeScale を記録
        this._originalTimeScale = this.restoreTimeScale;

        // 実際に timeScale を変更
        Engine.instance.timeScale = scale;

        // 状態更新
        this._isActive = true;
        this._remainingDuration = duration;
        this._lastRealTime = this._getRealTime();

        if (this.debugLog) {
            console.log('[ImpactShake] start hit stop: duration =', duration, 'scale =', scale);
        }
    }

    private _endHitStop(): void {
        // timeScale を元に戻す
        Engine.instance.timeScale = this.restoreTimeScale;

        if (this.debugLog) {
            console.log('[ImpactShake] end hit stop: restore timeScale =', this.restoreTimeScale);
        }

        // 状態リセット
        this._isActive = false;
        this._remainingDuration = 0;
    }

    private _getRealTime(): number {
        // game.frameTime は経過時間ではないので、director.getTotalTime() を利用
        // director.getTotalTime() はミリ秒単位のため、秒に変換する
        return director.getTotalTime() / 1000.0;
    }

    private _getRealDeltaTime(): number {
        const now = this._getRealTime();
        const dt = now - this._lastRealTime;
        this._lastRealTime = now;
        return dt;
    }

    private _clamp(value: number, min: number, max: number): number {
        return Math.min(max, Math.max(min, value));
    }

    onDisable() {
        // コンポーネントが無効化されたときに、ヒットストップ中なら安全に解除する。
        if (this._isActive) {
            if (this.debugLog) {
                console.warn('[ImpactShake] onDisable: force end hit stop.');
            }
            this._endHitStop();
        }
    }

    onDestroy() {
        // 破棄時も同様に timeScale を元に戻しておく。
        if (this._isActive) {
            if (this.debugLog) {
                console.warn('[ImpactShake] onDestroy: force end hit stop.');
            }
            this._endHitStop();
        }
    }
}

コードのポイント解説

  • triggerHitStop()
    • 攻撃ヒット時に呼び出す公開メソッドです。
    • 引数を省略すると、インスペクタで設定した hitStopDuration, hitStopScale が使われます。
    • すでにヒットストップ中の場合は、maxStackDuration を考慮して残り時間を延長します。
  • update()
    • 毎フレーム、ヒットストップの残り時間を減らし、0 以下になったら自動で解除します。
    • useUnscaledTimetrue のときは director.getTotalTime() を利用して timeScale 非依存の経過時間を計算します。
  • _startHitStop() / _endHitStop()
    • 内部的に timeScale の変更と状態管理を行うヘルパーメソッドです。
    • 終了時には restoreTimeScale で指定した値に戻します。
  • onDisable() / onDestroy()
    • コンポーネントが無効化・破棄されるときに、もしヒットストップ中なら強制的に解除して timeScale を元に戻します。
    • これにより「シーン遷移時に timeScale が 0.1 のまま残ってしまう」といった事故を防ぎます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ左下の Assets パネルで、スクリプトを置きたいフォルダを選択します(例: assets/scripts)。
  2. 空いているところで右クリック → CreateTypeScript を選択します。
  3. ファイル名を ImpactShake.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、先ほどのコード全文を貼り付けて保存します。

2. テスト用ノードにアタッチ

  1. Hierarchy パネルで、シーンルートの Canvas または任意の管理用ノード(例: GameRoot)を選択します。
    • ヒットストップはゲーム全体に作用するため、どのノードに付けても動きますが、シーンに 1 つだけ付けておくのがおすすめです。
  2. 選択したノードの Inspector パネルで、Add Component ボタンをクリックします。
  3. CustomImpactShake を選択してアタッチします。

3. インスペクタでプロパティを設定

Inspector 上で、ImpactShake コンポーネントのプロパティを確認し、以下のように設定してみます。

  • Enabled On Start: true
  • Hit Stop Scale: 0.1(かなり強いスロー)
  • Hit Stop Duration: 0.070.1 秒程度
  • Max Stack Duration: 0.2
  • Restore Time Scale: 1.0
  • Use Unscaled Time: true
  • Debug Log: 動作確認中は true にしてログを見ると分かりやすいです。

4. 攻撃スクリプトから呼び出してみる

次に、攻撃判定が当たったときに triggerHitStop() を呼び出すようにします。ここでは例として、攻撃用のスクリプト SimpleAttack.ts があると仮定します。

すでに攻撃スクリプトがある場合は、その中に以下のようなコードを組み込んでください。


import { _decorator, Component, Node } from 'cc';
import { ImpactShake } from './ImpactShake';
const { ccclass, property } = _decorator;

@ccclass('SimpleAttack')
export class SimpleAttack extends Component {

    @property({
        tooltip: 'ImpactShake コンポーネントを持つノードを指定します。\n未指定の場合はシーン内から自動検索を試みます。',
        type: Node
    })
    public impactShakeNode: Node | null = null;

    private _impactShake: ImpactShake | null = null;

    start() {
        // 参照が指定されていればそこから取得
        if (this.impactShakeNode) {
            this._impactShake = this.impactShakeNode.getComponent(ImpactShake);
        } else {
            // 未指定の場合はシーン内から ImpactShake を探す
            this._impactShake = this.node.scene?.getComponentInChildren(ImpactShake) ?? null;
        }

        if (!this._impactShake) {
            console.error('[SimpleAttack] ImpactShake がシーン内に見つかりません。Canvas などのノードに ImpactShake をアタッチしてください。');
        }
    }

    /**
     * 何らかの方法で攻撃がヒットしたときに呼び出される想定のメソッド。
     */
    public onHit() {
        if (this._impactShake) {
            // デフォルト設定でヒットストップ
            this._impactShake.triggerHitStop();

            // あるいは、個別に強さを指定することも可能
            // this._impactShake.triggerHitStop(0.12, 0.05);
        }
    }
}

このように、攻撃判定のスクリプトから ImpactShake を取得し、onHit() などのヒット時イベントで triggerHitStop() を呼び出すだけで、ヒットストップが動作します。

5. 実際に動作を確認する

  1. シーンに ImpactShake をアタッチしたノードが 1 つ存在することを確認します。
  2. 攻撃スクリプト(例: SimpleAttack)を適当なノードにアタッチし、Inspector で ImpactShake Node に ImpactShake を持つノードをドラッグ&ドロップで指定します(未指定でも自動検索を試みます)。
  3. 攻撃がヒットしたタイミングで onHit() が呼ばれるようにセットアップします(ボタン押下、コリジョンイベントなど)。
  4. Preview(▶ ボタン) でゲームを再生し、攻撃をヒットさせてみましょう。
  5. ヒットした瞬間に、画面全体が一瞬スローになり、その後すぐに元の速度に戻れば成功です。
  6. Debug Logtrue にしている場合、[ImpactShake] start hit stop ... / [ImpactShake] end hit stop ... のログが Console に出力されることも確認できます。

まとめ

今回作成した ImpactShake コンポーネントは、

  • Engine.timeScale を一時的に下げて打撃感を出す「ヒットストップ」機能を、シーンに 1 つアタッチするだけ で提供する汎用スクリプトです。
  • 外部の GameManager やシングルトンに依存せず、完全に独立 して動作します。
  • ヒットストップの強さ(hitStopScale)長さ(hitStopDuration)連続ヒット時の上限(maxStackDuration) などをインスペクタから簡単に調整できます。
  • 攻撃スクリプトから triggerHitStop() を呼ぶだけでよく、既存のプロジェクトにも導入しやすい構成になっています。

このコンポーネントをベースに、さらに以下のような拡張も容易に行えます。

  • ヒットストップ中にカメラシェイクを同時に発生させる
  • 強攻撃・弱攻撃で triggerHitStop() の引数を変えてメリハリをつける
  • ボス戦など特定のシーンでは enabledOnStart を切り替えて演出を制御する

コンポーネント単体で完結する設計にしておくことで、プロジェクト間の流用やチーム内での共有も容易になります。ぜひ自分のアクションゲームに組み込んで、手触りを一段階引き上げてみてください。