Godot 4 でアクションゲームや演出を作っていると、Engine.time_scale をいじってスローモーション演出を入れたくなること、多いですよね。
でも、素直に実装しようとすると:

  • プレイヤーのスクリプトの中に「被弾時に Engine.time_scale = 0.2」みたいなロジックを書き始める
  • 敵の必殺技でもスローを使いたくなって、また別のスクリプトに同じような処理を書く
  • 結果として「どこで time_scale を変えてるのか」が散らばってカオスになる

…という「あるある」な状態になりがちです。
さらに、タイムスケールを戻し忘れてゲームがずっとスローのまま、なんてバグも起きやすいですね。

そこで今回は、「継承でプレイヤーや敵にスロー機能を埋め込む」のではなく、どのノードにも付け替え可能なコンポーネントとして、スローモーション演出を切り出してしまいましょう。
その名も SlowMotion コンポーネント。特定条件で Engine.time_scale を操作する、汎用トリガー部品として使えるようにしていきます。

【Godot 4】一瞬で“世界を遅くする”トリガー!「SlowMotion」コンポーネント

今回のコンポーネントのゴールはこんな感じです:

  • 任意のノードにアタッチするだけで「スロー演出トリガー」になる
  • シグナルで発火するので、プレイヤー・敵・ギミックなどから簡単に呼べる
  • 自動で元の time_scale に戻す(戻し忘れ防止)
  • フェードイン / フェードアウトで、スローの入り方・戻り方を滑らかに制御

つまり、ゲーム中の「スロー演出」はすべてこのコンポーネントに任せてしまい、各キャラやギミックは「スローして」とシグナルを送るだけ、という構成にします。


フルコード:SlowMotion.gd


extends Node
class_name SlowMotion
## 特定条件で Engine.time_scale を操作するスローモーショントリガーコンポーネント。
##
## 使い方のイメージ:
##   - 任意のシーンにこのコンポーネントを 1 つ置く(グローバルでもローカルでもOK)
##   - プレイヤーや敵から `request_slow_motion()` をシグナルで呼ぶ
##   - 一定時間だけスローになり、自動で元の time_scale に戻る

signal slow_started(target_scale: float)
signal slow_ended()

## --- 基本設定 ---

@export_range(0.01, 1.0, 0.01)
var slow_scale: float = 0.2:
    set(value):
        slow_scale = clamp(value, 0.01, 1.0)

## スロー継続時間(秒)
@export_range(0.0, 10.0, 0.1)
var slow_duration: float = 0.5

## スローに入るまでのフェード時間(秒)
@export_range(0.0, 5.0, 0.05)
var fade_in_time: float = 0.05

## 元の速度に戻るまでのフェード時間(秒)
@export_range(0.0, 5.0, 0.05)
var fade_out_time: float = 0.2

## 既にスロー中に再度リクエストが来たときの挙動
enum OverlapMode {
    IGNORE,     ## 無視する(今のスローが終わるまで何もしない)
    RESTART,    ## タイマーをリセットして、指定時間からやり直す
    STACK_TIME  ## 残り時間に slow_duration を加算(最大値は max_stack_duration)
}

@export var overlap_mode: OverlapMode = OverlapMode.RESTART

## STACK_TIME モード時の最大合計時間
@export_range(0.0, 30.0, 0.1)
var max_stack_duration: float = 3.0

## デバッグログを出すかどうか
@export var debug_log: bool = false

## --- 内部状態 ---

var _original_time_scale: float = 1.0
var _is_slow_active: bool = false
var _remaining_slow_time: float = 0.0
var _fade_in_timer: float = 0.0
var _fade_out_timer: float = 0.0
var _in_fade_in: bool = false
var _in_fade_out: bool = false

func _ready() -> void:
    # 起動時の time_scale を保存しておく
    _original_time_scale = Engine.time_scale
    set_process(true)

    if debug_log:
        print("[SlowMotion] Ready. Original time_scale = ", _original_time_scale)


## 外部から呼ぶ用のメインAPI
## - 例: プレイヤーが被弾したときに呼ぶ
func request_slow_motion(
        custom_slow_scale: float = -1.0,
        custom_duration: float = -1.0
    ) -> void:
    ## 引数で値が指定されていなければ、export されたデフォルト値を使う
    var target_scale := slow_scale if custom_slow_scale <= 0.0 else clamp(custom_slow_scale, 0.01, 1.0)
    var duration := slow_duration if custom_duration < 0.0 else max(custom_duration, 0.0)

    if duration == 0.0:
        # duration 0 は無意味なので無視
        if debug_log:
            print("[SlowMotion] request ignored: duration == 0")
        return

    if _is_slow_active:
        match overlap_mode:
            OverlapMode.IGNORE:
                if debug_log:
                    print("[SlowMotion] request ignored: already active (IGNORE)")
                return
            OverlapMode.RESTART:
                if debug_log:
                    print("[SlowMotion] request: restart slow motion")
                _start_slow_internal(target_scale, duration)
            OverlapMode.STACK_TIME:
                if debug_log:
                    print("[SlowMotion] request: stack time")
                _remaining_slow_time = min(_remaining_slow_time + duration, max_stack_duration)
                # すでにスロー中なので、scale だけ更新するかどうかは好み。
                # ここでは「新しい target_scale を採用」する。
                _set_time_scale_immediately(target_scale)
    else:
        if debug_log:
            print("[SlowMotion] request: start new slow motion")
        _start_slow_internal(target_scale, duration)


func _start_slow_internal(target_scale: float, duration: float) -> void:
    _is_slow_active = true
    _remaining_slow_time = duration
    _in_fade_in = fade_in_time > 0.0
    _in_fade_out = false
    _fade_in_timer = 0.0
    _fade_out_timer = 0.0

    # フェードインが 0 の場合、即座に time_scale を変更
    if not _in_fade_in:
        _set_time_scale_immediately(target_scale)
    else:
        # フェードイン開始時は元のスケールから補間する
        _set_time_scale_immediately(_original_time_scale)

    emit_signal("slow_started", target_scale)


func _process(delta: float) -> void:
    # time_scale 自体をいじっているので、ゲーム全体の delta が変化する点に注意。
    # ここでは Engine.get_physics_ticks_per_second() などは使わず、単純に delta ベースで管理。
    if not _is_slow_active:
        return

    # スロー中の経過時間を減らす
    if _remaining_slow_time > 0.0 and not _in_fade_out:
        _remaining_slow_time -= delta
        if _remaining_slow_time <= 0.0:
            # スロー継続時間が終わったのでフェードアウトへ
            _in_fade_out = fade_out_time > 0.0
            _in_fade_in = false
            _fade_out_timer = 0.0

            if not _in_fade_out:
                # フェードアウト時間が 0 なら即座に元のスケールへ戻す
                _end_slow_immediately()

    # フェードイン処理
    if _in_fade_in:
        _fade_in_timer += delta
        var t := clamp(_fade_in_timer / max(fade_in_time, 0.0001), 0.0, 1.0)
        var target_scale := slow_scale
        # 補間: 元のスケール → target_scale
        var new_scale := lerp(_original_time_scale, target_scale, t)
        Engine.time_scale = new_scale

        if t >= 1.0:
            _in_fade_in = false
            Engine.time_scale = target_scale

    # フェードアウト処理
    if _in_fade_out:
        _fade_out_timer += delta
        var t2 := clamp(_fade_out_timer / max(fade_out_time, 0.0001), 0.0, 1.0)
        var from_scale := Engine.time_scale
        var new_scale2 := lerp(from_scale, _original_time_scale, t2)
        Engine.time_scale = new_scale2

        if t2 >= 1.0:
            _end_slow_immediately()


func _set_time_scale_immediately(target_scale: float) -> void:
    Engine.time_scale = target_scale


func _end_slow_immediately() -> void:
    Engine.time_scale = _original_time_scale
    _is_slow_active = false
    _in_fade_in = false
    _in_fade_out = false
    _remaining_slow_time = 0.0
    emit_signal("slow_ended")

    if debug_log:
        print("[SlowMotion] slow ended. time_scale restored to ", _original_time_scale)


## ゲーム終了時やシーン破棄時に time_scale を元に戻しておく安全策
func _exit_tree() -> void:
    Engine.time_scale = _original_time_scale
    if debug_log:
        print("[SlowMotion] exit_tree: time_scale restored to ", _original_time_scale)


## 手動でスローを強制終了したいとき用のAPI
func cancel_slow() -> void:
    if not _is_slow_active:
        return
    _end_slow_immediately()

使い方の手順

今回は、プレイヤーが被弾したときにスローになる例をベースに説明します。
ただし、敵の必殺技や動く罠の発動タイミングなどにも、そのまま使い回せます。

手順①:コンポーネントをプロジェクトに追加

  1. res://components/SlowMotion.gd など、好きな場所に上記コードを保存します。
  2. Godot エディタで再読み込みすると、クラス名 SlowMotion がエディタから選べるようになります。

手順②:シーンに SlowMotion ノードを1つ置く

シーン構成の一例:

Main (Node)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PlayerController (Script)
 ├── EnemySpawner (Node)
 └── SlowMotion (SlowMotion)  ← ここにコンポーネントを1つだけ置く

ポイント:

  • SlowMotion は1シーンに1つでOK(ゲーム全体で共有するタイムスケールなので)。
  • グローバルに使いたい場合は、Autoload シングルトンとして登録してもよいです。

手順③:プレイヤーから SlowMotion にアクセスして呼び出す

シンプルな例として、プレイヤーが敵にダメージを受けたときにスローを発動してみましょう。

PlayerController.gd(抜粋):


extends CharacterBody2D

@onready var slow_motion: SlowMotion = get_node("/root/Main/SlowMotion")
# ↑ シーン構成に合わせてパスは調整してください。
#   Autoload にしているなら `get_node("/root/SlowMotion")` など。

func _on_hit_by_enemy() -> void:
    # 被弾時にスローをリクエスト
    # 引数なしなら、SlowMotion コンポーネント側の export 値を使用
    slow_motion.request_slow_motion()

    # もっと強い被弾時には、より長いスローを指定してもOK
    # slow_motion.request_slow_motion(0.1, 1.0)  # かなり強めのスローを1秒

このように、プレイヤー側には「スローの具体的な実装」を書かないのがポイントです。
「スローして」と依頼するだけで、演出の細かい調整はすべて SlowMotion コンポーネント側に閉じ込めます。

手順④:敵やギミックからも同じコンポーネントを使い回す

敵の必殺技でスローを使いたい場合も、同じコンポーネントを参照すればOKです。


extends Node2D

@onready var slow_motion: SlowMotion = get_node("/root/Main/SlowMotion")

func _on_super_attack_fired() -> void:
    # 必殺技発動時に短くスロー
    slow_motion.request_slow_motion(0.3, 0.3)

シーン構成イメージ:

Main (Node)
 ├── Player (CharacterBody2D)
 │    └── PlayerController (Script)
 ├── Boss (Node2D)
 │    └── BossAI (Script)
 └── SlowMotion (SlowMotion)

プレイヤーもボスも、同じ1つの SlowMotion コンポーネントを共有している状態です。
「スロー演出の中身」をどこか1か所で管理できるので、バランス調整がとても楽になりますね。


メリットと応用

この SlowMotion コンポーネントを導入することで、次のようなメリットがあります。

  • シーン構造がスッキリ
    プレイヤーや敵のスクリプトに Engine.time_scale の操作を埋め込まずに済むので、各スクリプトは「自分の責務」に集中できます。
  • 演出の一元管理
    スローの強さ・長さ・フェード時間などを、SlowMotion コンポーネントの export 値だけで調整可能。
    バランス調整時に「全シーンの全スクリプト」を探し回る必要がなくなります。
  • 使い回しが容易
    別プロジェクトにも SlowMotion.gd をコピペして、シーンに1つ置くだけで同じ仕組みが使えます。
  • 「継承の呪い」からの解放
    「スロー機能付きプレイヤー」「スロー機能付き敵」みたいなクラスを継承で増やす必要がありません。
    どのノードでも、コンポーネントに向かってシグナルを送るだけでスローを発動できます。

さらに応用として、スロー開始・終了に合わせて画面エフェクトを入れるのも簡単です。
たとえば、SlowMotion コンポーネントに「画面を少し暗くする」処理を追加してみましょう。

改造案:スロー中だけ画面を暗くする

以下は、すでにシーン内に「画面暗転用の ColorRect」がある前提の改造案です。


@onready var dimmer: ColorRect = get_node_or_null("../ScreenDimmer")

func _on_slow_started(target_scale: float) -> void:
    if dimmer:
        dimmer.visible = true
        dimmer.modulate.a = 0.5  # 半透明にする

func _on_slow_ended() -> void:
    if dimmer:
        dimmer.visible = false

実際には、slow_started / slow_ended シグナルを使って、別ノード側で画面エフェクトを制御する方が「責務の分離」としてはよりキレイです。
たとえば:

Main (Node)
 ├── SlowMotion (SlowMotion)
 ├── ScreenDimmer (ColorRect)
 └── SlowMotionEffects (Node) ← ここでシグナルを受けて画面エフェクトを制御

こんな感じで、「スロー演出の中心は SlowMotion コンポーネント」「見た目のエフェクトは別コンポーネント」と分けていくと、プロジェクトが大きくなっても破綻しにくくなりますね。
継承ではなく、コンポーネントを組み合わせてゲームを組み立てるスタイルで、どんどん Godot 4 ライフを快適にしていきましょう。