【Cocos Creator 3.8】TimeFreeze(ヒットストップ)の実装:アタッチするだけで「一瞬だけ時間を止めて攻撃の重みを表現」できる汎用スクリプト

強攻撃や必殺技のヒット時に、ゲーム全体の時間を一瞬だけ止める「ヒットストップ」は、攻撃の重みや爽快感を大きく向上させます。本記事では、任意のノードにアタッチするだけで、コードから簡単に「時間停止(timeScale制御)」を呼び出せる汎用コンポーネント TimeFreeze を実装します。

このコンポーネントは、他のカスタムスクリプトに依存せず、単体で完結しており、インスペクタから停止時間やスケール値を調整できます。攻撃判定スクリプトなどから triggerFreeze() を呼ぶだけで、ヒットストップ演出を簡単に組み込めます。


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

1. 実現したい機能

  • ゲーム全体の時間を、一時的にスロー(またはほぼ停止)させる
  • デフォルト仕様:
    • timeScale = 0.05 に変更し、
    • 0.05秒間 だけ維持した後、
    • 元の timeScale に自動で戻す。
  • 複数回連続で呼ばれても、最後に呼ばれたヒットストップで上書きされ、必ず正常に元の timeScale に戻る。
  • ゲーム内のどこからでも呼びやすいように、他のノードやグローバルシングルトンに依存しない

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

  • cc.director に直接アクセスし、getScheduler().setTimeScale() を操作する。
  • 元の timeScale を TimeFreeze 内部に保存し、終了時に必ず復元する。
  • ヒットストップのトリガーはメソッド呼び出しで行い、他のコンポーネントは this.node.getComponent(TimeFreeze)?.triggerFreeze() のように呼ぶだけでよい。
  • 経過時間管理は update() で行い、timeScale とは無関係な実時間(RealTime) を使うことで、停止中でも正しく解除されるようにする。

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

TimeFreeze コンポーネントでは、以下のプロパティをインスペクタから調整できるようにします。

  • enabledOnStart: boolean
    • デフォルト: true
    • コンポーネントが有効かどうか。false にすると triggerFreeze() を呼んでも何も起きません。
  • freezeScale: number
    • デフォルト: 0.05
    • ヒットストップ中に設定する timeScale の値。
    • 0.0 に近いほど「完全停止」に近づきますが、物理やアニメーションの更新が止まることに注意。
  • freezeDuration: number
    • デフォルト: 0.05(秒)
    • ヒットストップを継続する実時間(リアル秒)。
    • 0.02~0.1 秒程度が一般的なヒットストップの感覚です。
  • maxStackedDuration: number
    • デフォルト: 0.15(秒)
    • 連続で triggerFreeze() が呼ばれた場合に、最大でもこの時間を超えて停止しないようにする上限値。
    • 連続ヒット時にヒットストップが長くなりすぎるのを防ぐため。
  • logDebug: boolean
    • デフォルト: false
    • true にすると、ヒットストップ開始・終了時に console.log を出力して挙動を確認できます。

この設計により、TimeFreeze 自体はどのノードにアタッチしても同じように動作し、インスペクタから演出の強さを簡単に調整できます。


TypeScriptコードの実装

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


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

/**
 * TimeFreeze
 * 
 * 任意のタイミングで triggerFreeze() を呼ぶと、
 * ゲーム全体の timeScale を一時的に変更してヒットストップ演出を行うコンポーネント。
 * 
 * - 他のカスタムスクリプトへの依存なし
 * - インスペクタから停止時間やスケールを調整可能
 */
@ccclass('TimeFreeze')
export class TimeFreeze extends Component {

    @property({
        tooltip: 'コンポーネントが有効な場合のみヒットストップが動作します。false にすると triggerFreeze() は無視されます。',
    })
    public enabledOnStart: boolean = true;

    @property({
        tooltip: 'ヒットストップ中に設定する timeScale の値。\n0 に近いほど完全停止に近づきます(例: 0.05)。',
        min: 0.0,
        max: 1.0,
        step: 0.01,
    })
    public freezeScale: number = 0.05;

    @property({
        tooltip: 'ヒットストップを継続する実時間(秒)。\n0.02〜0.1 秒程度が一般的です。',
        min: 0.0,
        step: 0.01,
    })
    public freezeDuration: number = 0.05;

    @property({
        tooltip: '連続で triggerFreeze() が呼ばれたときの、最大ヒットストップ時間の上限(秒)。\n0 以下にすると上限なしになります。',
        min: 0.0,
        step: 0.01,
    })
    public maxStackedDuration: number = 0.15;

    @property({
        tooltip: 'true にすると、ヒットストップ開始・終了時にログを出力します。',
    })
    public logDebug: boolean = false;

    // 内部状態管理用
    private _isFreezing: boolean = false;
    private _originalTimeScale: number = 1.0;
    private _freezeEndTime: number = 0; // 実時間(ミリ秒)で保持

    onLoad() {
        // コンポーネントの有効・無効を初期化
        this.enabled = this.enabledOnStart;

        // director が取得できるか一応チェック(通常は必ず存在)
        if (!director) {
            console.error('[TimeFreeze] director が取得できません。timeScale を変更できません。');
        }
    }

    /**
     * 攻撃ヒット時などに呼び出して、ヒットストップを開始します。
     * 
     * @param duration 秒数(省略時は freezeDuration を使用)
     * @param scale    timeScale 値(省略時は freezeScale を使用)
     */
    public triggerFreeze(duration?: number, scale?: number): void {
        if (!this.enabled) {
            if (this.logDebug) {
                console.log('[TimeFreeze] コンポーネントが無効のため、triggerFreeze() は無視されました。');
            }
            return;
        }

        if (!director) {
            console.error('[TimeFreeze] director が利用できないため、triggerFreeze() を実行できません。');
            return;
        }

        const now = performance.now();

        const useDuration = (duration !== undefined ? duration : this.freezeDuration);
        const useScale = (scale !== undefined ? scale : this.freezeScale);

        if (useDuration <= 0) {
            if (this.logDebug) {
                console.log('[TimeFreeze] duration が 0 以下のため、ヒットストップは実行されません。');
            }
            return;
        }

        // 既にヒットストップ中の場合は、終了時刻を延長 or 上書き
        let newEndTime = now + useDuration * 1000.0;

        // 上限時間が設定されている場合、現在時刻からの最大値を制限
        if (this.maxStackedDuration > 0) {
            const maxEndTime = now + this.maxStackedDuration * 1000.0;
            if (newEndTime > maxEndTime) {
                newEndTime = maxEndTime;
            }
        }

        if (!this._isFreezing) {
            // 初回開始時に元の timeScale を保存
            this._originalTimeScale = director.getScheduler().getTimeScale();

            // timeScale を即座に変更
            director.getScheduler().setTimeScale(useScale);
            this._isFreezing = true;

            if (this.logDebug) {
                console.log(
                    `[TimeFreeze] ヒットストップ開始: scale=${useScale}, duration=${useDuration.toFixed(3)}s, ` +
                    `originalScale=${this._originalTimeScale}`
                );
            }
        } else {
            if (this.logDebug) {
                console.log('[TimeFreeze] 既にヒットストップ中。終了時間を延長/更新します。');
            }
        }

        // 終了時刻を更新(延長 or 新規)
        this._freezeEndTime = newEndTime;
    }

    /**
     * 外部から強制的にヒットストップを解除したい場合に呼び出します。
     * 通常は自動で解除されるため、特別な状況でのみ使用してください。
     */
    public forceEndFreeze(): void {
        if (!this._isFreezing) {
            return;
        }
        if (!director) {
            console.error('[TimeFreeze] director が利用できないため、forceEndFreeze() を実行できません。');
            return;
        }

        director.getScheduler().setTimeScale(this._originalTimeScale);
        this._isFreezing = false;

        if (this.logDebug) {
            console.log('[TimeFreeze] forceEndFreeze() によりヒットストップを強制終了しました。');
        }
    }

    update(deltaTime: number) {
        // 実時間でヒットストップ解除タイミングを管理(timeScale の影響を受けない)
        if (!this._isFreezing) {
            return;
        }
        const now = performance.now();
        if (now >= this._freezeEndTime) {
            if (!director) {
                console.error('[TimeFreeze] director が利用できないため、timeScale を元に戻せません。');
                this._isFreezing = false;
                return;
            }

            director.getScheduler().setTimeScale(this._originalTimeScale);
            this._isFreezing = false;

            if (this.logDebug) {
                console.log(
                    `[TimeFreeze] ヒットストップ終了: timeScale を ${this._originalTimeScale} に復元しました。`
                );
            }
        }
    }

    onDisable() {
        // コンポーネントが無効化されたときに、もしヒットストップ中なら元に戻しておく
        if (this._isFreezing && director) {
            director.getScheduler().setTimeScale(this._originalTimeScale);
            this._isFreezing = false;

            if (this.logDebug) {
                console.log('[TimeFreeze] コンポーネント無効化に伴い、timeScale を復元しました。');
            }
        }
    }

    onDestroy() {
        // ノード破棄時も安全のため timeScale を復元
        if (this._isFreezing && director) {
            director.getScheduler().setTimeScale(this._originalTimeScale);
            this._isFreezing = false;

            if (this.logDebug) {
                console.log('[TimeFreeze] コンポーネント破棄に伴い、timeScale を復元しました。');
            }
        }
    }
}

コードのポイント解説

  • onLoad
    • enabledOnStart の値で this.enabled を初期化し、コンポーネントが最初から有効かどうかを決めます。
    • director の存在を確認し、取得できない場合はエラーログを出します(通常は取得できますが、防御的実装)。
  • triggerFreeze(duration?, scale?)
    • 他のスクリプトから呼び出して、ヒットストップを開始するメソッドです。
    • 引数を省略した場合は、インスペクタで設定した freezeDurationfreezeScale を使用します。
    • すでにヒットストップ中の場合は、終了時刻だけを延長/更新し、timeScale の再保存は行いません。
    • performance.now() を使って、実際の経過時間(リアルタイム) ベースで終了時刻を計算します。
    • maxStackedDuration によって、連続ヒット時の停止時間が長くなりすぎないように制限します。
  • update(deltaTime)
    • 毎フレーム、performance.now() で現在時刻を取得し、_freezeEndTime を超えたらヒットストップを終了します。
    • director.getScheduler().setTimeScale(this._originalTimeScale) で元のスケールに戻します。
    • timeScale を 0 近くにしていても、update は呼ばれ続ける & performance.now() は進むため、確実に解除されます。
  • onDisable / onDestroy
    • コンポーネントが無効化・破棄されたときに、もしヒットストップ中であれば timeScale を元に戻します。
    • これにより、「TimeFreeze をアタッチしたノードを削除したら timeScale が戻らない」といった事故を防ぎます。
  • forceEndFreeze()
    • 何らかの理由で「今すぐヒットストップを解除したい」場合に呼べる保険的メソッドです。
    • 通常の利用では triggerFreeze() だけで十分です。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、ヒットストップ用スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. フォルダ上で右クリック → CreateTypeScript を選択します。
  3. 新しく作成されたファイル名を TimeFreeze.ts に変更します。
  4. ダブルクリックして開き、既存のテンプレートコードをすべて削除し、前章の TypeScriptコードの実装 セクションにあるコードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成

TimeFreeze はどのノードにアタッチしても機能します。ここでは分かりやすくするために、専用の空ノードを作ります。

  1. 上部メニューから GameObjectCreate Empty Node(または Hierarchy パネル右クリック → CreateEmpty Node)を選択します。
  2. 作成されたノードの名前を TimeFreezeController など、分かりやすい名前に変更します。

3. TimeFreeze コンポーネントをアタッチ

  1. Hierarchy で TimeFreezeController ノードを選択します。
  2. Inspector パネルの下部にある Add Component ボタンをクリックします。
  3. CustomTimeFreeze を選択します。
    • もし一覧に出てこない場合は、スクリプトの保存とコンパイルが終わっているか確認し、エディタを一度フォーカスし直してください。

4. プロパティの設定例

Inspector で TimeFreeze コンポーネントが表示されている状態で、以下のように設定してみます。

  • Enabled On Start: true
  • Freeze Scale: 0.05
  • Freeze Duration: 0.05
  • Max Stacked Duration: 0.15
  • Log Debug: true(挙動確認のため、一旦オンにするのを推奨)

5. 実際にヒットストップを呼び出す

TimeFreeze 自体は「時間を止める機能」を提供するだけなので、「いつ止めるか」は攻撃判定など別スクリプトから呼び出します。ここでは簡単なテスト用スクリプトを作って、キー入力でヒットストップを発生させてみます。

  1. Assets パネルで右クリック → CreateTypeScript を選択し、TestTimeFreeze.ts という名前を付けます。
  2. 以下のコードを貼り付けます(このスクリプトはあくまでテスト用であり、TimeFreeze 本体には依存していません)。

import { _decorator, Component, input, Input, EventKeyboard, KeyCode } from 'cc';
import { TimeFreeze } from './TimeFreeze';
const { ccclass } = _decorator;

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

    private _timeFreeze: TimeFreeze | null = null;

    onLoad() {
        // 同じノードにアタッチされた TimeFreeze を取得
        this._timeFreeze = this.getComponent(TimeFreeze);
        if (!this._timeFreeze) {
            console.error('[TestTimeFreeze] 同じノードに TimeFreeze コンポーネントが見つかりません。');
        }

        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    private onKeyDown(event: EventKeyboard) {
        if (event.keyCode === KeyCode.SPACE) {
            if (this._timeFreeze) {
                // スペースキーでヒットストップをトリガー
                this._timeFreeze.triggerFreeze();
            }
        }
    }
}

このテストスクリプトを使うには:

  1. Hierarchy で先ほど作成した TimeFreezeController ノードを選択します。
  2. Inspector の Add ComponentCustomTestTimeFreeze を追加します。
  3. TimeFreezeController ノードには
    • TimeFreeze
    • TestTimeFreeze

    の2つのコンポーネントが付いた状態になります。

6. 再生して動作確認

  1. エディタ上部の Play ボタン(▶)を押してゲームを再生します。
  2. ゲームウィンドウにフォーカスした状態で、スペースキー を押します。
  3. 押した瞬間に、ゲーム全体が約0.05秒間だけスロー(またはほぼ停止)し、その後元のスピードに戻るのを確認します。
  4. Log Debugtrue にしている場合は、Console タブに
    • 「ヒットストップ開始」
    • 「ヒットストップ終了」

    のログが出力されているはずです。

7. 実際のゲームでの使い方例

攻撃判定スクリプトなどから TimeFreeze を使う場合のイメージは以下の通りです。


import { _decorator, Component } from 'cc';
import { TimeFreeze } from './TimeFreeze';
const { ccclass } = _decorator;

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

    // シーン内のどこかにある TimeFreeze コンポーネントへの参照を
    // インスペクタからドラッグ&ドロップで設定する想定
    @property(TimeFreeze)
    public timeFreeze: TimeFreeze | null = null;

    // 何らかの方法で攻撃がヒットしたときに呼ばれるメソッド
    public onHitStrongAttack() {
        if (this.timeFreeze) {
            // 強攻撃用に、少し長め&強めのヒットストップをかける例
            this.timeFreeze.triggerFreeze(0.08, 0.03);
        }

        // ここでダメージ処理などを行う...
    }
}

このように、TimeFreeze 自体は単体で完結しつつ、どのスクリプトからでも簡単に呼び出せるように設計されています。


まとめ

  • TimeFreeze コンポーネントは、ゲーム全体の timeScale を一時的に変更することでヒットストップ演出を行う汎用スクリプトです。
  • インスペクタから
    • 停止中の timeScale 値(freezeScale)
    • 停止時間(freezeDuration)
    • 連続ヒット時の最大停止時間(maxStackedDuration)

    を調整でき、演出の強さを簡単にチューニングできます。

  • 内部では director.getScheduler().setTimeScale() を直接操作し、performance.now() を使って実時間ベースで解除タイミングを管理しているため、timeScale に依存せず安全に復帰できます。
  • 他のカスタムスクリプトへの依存が一切なく、どのノードにもアタッチして使えるため、プロジェクト間の再利用性が高いコンポーネントになっています。

この TimeFreeze をプロジェクトのベースに組み込んでおけば、強攻撃・必殺技・被弾時などに「重みのある一瞬の停止」を簡単に追加でき、アクションゲームの手触りを大きく向上させられます。あとは各攻撃スクリプトから triggerFreeze() を呼ぶだけで、どんなゲームにも手軽にヒットストップ演出を導入できます。