アクションゲームをGodotで作っていると、強攻撃に「重み」を出したくなりますよね。
多くの人は、攻撃スクリプトの中に直接 Engine.time_scale の変更を書いてしまいがちですが、そうすると…

  • プレイヤー、敵、ギミックごとに同じような「ヒットストップ処理」をコピペする
  • 「あれ、このシーンだけヒットストップが効かない…」と原因調査がつらい
  • 将来、ヒットストップの仕様を変えたくなった時に、全シーンを探し回る羽目になる

これはまさに「継承&ベタ書き地獄」の入り口ですね。
そこで今回は、どのシーンにもポン付けできる「TimeFreeze (ヒットストップ)」コンポーネントを用意して、攻撃スクリプトからは「フックを呼ぶだけ」にする構成にしてみましょう。

やることはシンプルで、強攻撃ヒット時に Engine.time_scale を一瞬だけ下げて、すぐ元に戻すだけです。
でもこれをコンポーネントに切り出しておくことで、

  • プレイヤー
  • 敵キャラ
  • ボス専用のド派手な攻撃

など、どこからでも time_freeze.trigger() を呼ぶだけで、統一されたヒットストップが使えるようになります。

【Godot 4】一撃の重みを一瞬で演出!「TimeFreeze」コンポーネント

フルコード(GDScript / Godot 4)


extends Node
class_name TimeFreeze
## ヒットストップ(時間停止)を簡単に付けられるコンポーネント。
## 任意のノードにアタッチして、攻撃ヒット時などに `trigger()` を呼び出すだけでOK。

@export_range(0.0, 1.0, 0.01)
var freeze_time_scale: float = 0.05:
	## ヒットストップ中に設定する time_scale。
	## 0.0 だと完全停止、1.0 だと通常速度。
	## 0.02〜0.1 くらいが「止まった感」が出やすいです。
	set(value):
		freeze_time_scale = clamp(value, 0.0, 1.0)

@export_range(0.0, 0.5, 0.01)
var freeze_duration: float = 0.05
	## ヒットストップの長さ(秒)。
	## 0.02〜0.1 秒くらいが程よいです。
	## 注意: time_scale をいじるので、ゲーム全体の「実時間」での長さを意識しましょう。

@export var use_unscaled_time: bool = true
	## true の場合: "実時間"ベースでヒットストップを終了させる。
	## false の場合: time_scale の影響を受けた「ゲーム時間」ベースで終了させる。
	## 演出としては true を推奨。

@export var allow_overlap: bool = false
	## 連続して trigger() が呼ばれたときの挙動。
	## false: すでにヒットストップ中なら無視(多段ヒットでも一回分だけ効く)。
	## true: 呼ばれるたびにタイマーを延長(ボスの連撃で長く止めたい時など)。

var _original_time_scale: float = 1.0
var _is_freezing: bool = false
var _freeze_timer: float = 0.0

func _ready() -> void:
	# 念のため、起動時の time_scale を記録しておく。
	_original_time_scale = Engine.time_scale


func _process(delta: float) -> void:
	if not _is_freezing:
		return

	# 経過時間の進め方を選択
	var dt := delta
	if use_unscaled_time:
		# 実時間ベースにしたいので、time_scale の影響を打ち消す
		# (Engine.time_scale が 0.05 でも、実際の経過時間は通常通り進める)
		if Engine.time_scale != 0.0:
			dt = delta / Engine.time_scale
		else:
			# 完全停止の場合は OS.get_ticks_msec() を使う方法もあるが、
			# ここでは簡易的に「フレームごとに delta をそのまま加算」でも
			# ほぼ問題ないケースが多いです。
			dt = delta

	_freeze_timer -= dt
	if _freeze_timer <= 0.0:
		_end_freeze()


func trigger(duration: float = -1.0, time_scale: float = -1.0) -> void:
	## ヒットストップを開始する。
	## - duration: 負数なら export された freeze_duration を使用。
	## - time_scale: 負数なら export された freeze_time_scale を使用。
	##
	## 例:
	##   $TimeFreeze.trigger()                     # デフォルト設定でヒットストップ
	##   $TimeFreeze.trigger(0.1)                 # 0.1秒だけヒットストップ
	##   $TimeFreeze.trigger(0.08, 0.02)          # 0.08秒 / time_scale=0.02 に変更

	if duration < 0.0:
		duration = freeze_duration
	if time_scale < 0.0:
		time_scale = freeze_time_scale

	# すでにヒットストップ中 & 重ねがけ禁止の場合は無視
	if _is_freezing and not allow_overlap:
		return

	if not _is_freezing:
		# 最初のヒットストップ開始時だけ、元の time_scale を保存
		_original_time_scale = Engine.time_scale

	_is_freezing = true
	_freeze_timer = duration
	Engine.time_scale = time_scale


func cancel() -> void:
	## 外部から強制的にヒットストップを解除したい場合に呼ぶ。
	## 例: ポーズメニューを開くときなど。
	if _is_freezing:
		_end_freeze()


func _end_freeze() -> void:
	## 内部用: ヒットストップ終了処理
	_is_freezing = false
	_freeze_timer = 0.0
	Engine.time_scale = _original_time_scale

使い方の手順

ここでは、プレイヤーの強攻撃が敵にヒットしたときに TimeFreeze を発動する例で説明します。
敵やボス、ギミックでもやり方は同じです。

手順①:コンポーネントをシーンに追加する

まずはプレイヤーシーンの構成例です:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AttackArea (Area2D)
 │    └── CollisionShape2D
 └── TimeFreeze (Node)  ← このノードに上記スクリプトをアタッチ
  1. Godotエディタで、プレイヤーシーンを開く
  2. Player の子として Node を追加し、名前を TimeFreeze に変更
  3. そのノードに、先ほどの TimeFreeze.gd スクリプトをアタッチ
  4. インスペクタで
    • freeze_time_scale0.02〜0.05 くらい
    • freeze_duration0.04〜0.1 くらい

    に調整しておきましょう

手順②:攻撃スクリプトから TimeFreeze を呼ぶ

プレイヤーの攻撃判定(AttackArea)で敵にヒットした瞬間に、TimeFreeze.trigger() を呼びます。


# Player.gd (例)
extends CharacterBody2D

@onready var time_freeze: TimeFreeze = $TimeFreeze
@onready var attack_area: Area2D = $AttackArea

func _ready() -> void:
	attack_area.body_entered.connect(_on_attack_body_entered)


func _on_attack_body_entered(body: Node) -> void:
	# 強攻撃のヒット時だけヒットストップさせたい場合、
	# ここに「今が強攻撃中かどうか」のフラグチェックなどを入れるとよいです。
	if not _is_strong_attack():
		return

	# ダメージ処理など
	if body.has_method("apply_damage"):
		body.apply_damage(10)

	# ヒットストップを発動
	time_freeze.trigger()
	# あるいは、ここだけ個別設定したいなら:
	# time_freeze.trigger(0.08, 0.03)


func _is_strong_attack() -> bool:
	# ここは各自の実装に合わせてください。
	# 例: アニメーション名やステートマシンの状態を見て判定する。
	return true

このように、攻撃スクリプト側は「TimeFreeze コンポーネントにお願いする」だけにしておくと、
将来「やっぱりボス戦では長めに止めたい」「空中攻撃だけ短くしたい」などの調整も楽になります。

手順③:敵キャラやボスにも使い回す

敵キャラにも同じようにコンポーネントを付けると、「敵の強攻撃ヒットでも画面全体が一瞬止まる」演出が簡単にできます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AttackArea (Area2D)
 │    └── CollisionShape2D
 └── TimeFreeze (Node)

敵側のスクリプト例:


# Enemy.gd (例)
extends CharacterBody2D

@onready var time_freeze: TimeFreeze = $TimeFreeze
@onready var attack_area: Area2D = $AttackArea

func _ready() -> void:
	attack_area.body_entered.connect(_on_attack_body_entered)


func _on_attack_body_entered(body: Node) -> void:
	if not _is_strong_attack():
		return

	if body.has_method("apply_damage"):
		body.apply_damage(5)

	# プレイヤーに当たったら、敵側からもヒットストップをかける
	time_freeze.trigger(0.06, 0.04)


func _is_strong_attack() -> bool:
	return true

プレイヤーと敵で別々の TimeFreeze を持たせてもよいですし、
「ゲーム全体で一つの TimeFreeze を使い回したい」場合は、グローバルシーン(例: GameManager)に配置しておいて、get_tree().get_first_node_in_group() などで取得するのもアリです。

手順④:動く床やギミックにも演出として追加

例えば、重い岩が地面に叩きつけられた瞬間に、少しだけヒットストップを入れると「重量感」がぐっと増します。

FallingRock (RigidBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TimeFreeze (Node)

# FallingRock.gd (例)
extends RigidBody2D

@onready var time_freeze: TimeFreeze = $TimeFreeze

func _integrate_forces(state: PhysicsDirectBodyState2D) -> void:
	# 地面にぶつかった瞬間を検知して…
	if _just_hit_ground(state):
		time_freeze.trigger(0.04, 0.03)


func _just_hit_ground(state: PhysicsDirectBodyState2D) -> bool:
	# 実装は各自のゲームロジックに合わせてください。
	return false

こうやって「TimeFreeze コンポーネント」を色々なシーンにペタペタ貼っていくと、
演出の一貫性が保てる上に、個別のスクリプトは「ヒットしたらコンポーネントを呼ぶだけ」というシンプルな構造になります。


メリットと応用

  • コンポーネント化でシーン構造がスッキリ
    ヒットストップのロジックを各攻撃スクリプトに書かずに済むので、
    「ダメージ処理」と「演出処理」がきれいに分離できます。
  • 調整ポイントが一箇所にまとまる
    freeze_time_scalefreeze_duration をコンポーネント側で一括管理できるので、
    「ゲーム全体のヒットストップをちょっとだけ短くしたい」といった調整も楽です。
  • 再利用性が高い
    プレイヤー、敵、ギミック、ボス専用攻撃など、どこにでも同じコンポーネントを貼るだけでOK。
    「継承ツリーをいじらなくても、ノードを1個追加するだけ」で演出を共有できます。
  • テストがしやすい
    TimeFreeze 単体で挙動を確認できるので、「このノードの trigger を押したらどうなるか?」だけをテストすればよくなります。

さらに、TimeFreeze をちょっと改造して応用することもできます。
例えば、「ヒットストップ中はカメラシェイクも一緒に発動したい」場合、
以下のように _end_freeze() の直前でカメラに通知する関数を追加してみましょう。


func _end_freeze() -> void:
	_is_freezing = false
	_freeze_timer = 0.0
	Engine.time_scale = _original_time_scale

	# ここから応用: ヒットストップ終了時にカメラシェイクを発動
	var camera := get_tree().get_first_node_in_group("shake_camera")
	if camera and camera.has_method("shake"):
		camera.shake(0.2, 8.0)  # (duration, amplitude) など、カメラ側のAPIに合わせて

このように、「TimeFreeze は時間を止めるだけのコンポーネント」として小さく保ちつつ
必要に応じて他のコンポーネント(カメラシェイク、画面フラッシュ、SE再生など)と連携させていくと、
継承地獄に落ちることなく、合成(Composition)でリッチなアクション演出を積み上げていけます。