Godot 4でアクションゲームを作っていると、「攻撃が当たった感」をどう演出するかはかなり重要ですよね。
多くの人は最初、アニメーションを凝ったり、SEを強くしたり、画面シェイクを足したりしますが、一番手軽で効果が高いのが「ヒットストップ(時間を一瞬だけ遅くする)」です。

ただし、素直に実装しようとすると、

  • 各プレイヤーや敵のスクリプトごとに Engine.time_scale をいじる処理を書く
  • 複数の攻撃が同時にヒットしたとき、どのスクリプトが time_scale を戻すのか管理が面倒
  • 「この敵はヒットストップ弱め」「この攻撃は長め」などの調整がスクリプトに埋もれていく

といった「管理地獄」に陥りがちです。
しかも、Godot標準の作り方だと、PlayerEnemy のスクリプトがどんどん肥大化していき、継承ツリーもノード階層も深くなりがちなんですよね。

そこでこの記事では、どのノードにもポン付けできる「ヒットストップ専用コンポーネント」として、ImpactShake を用意します。
攻撃ヒット時にシグナルから呼ぶだけで、Engine.time_scale を一瞬だけ下げてくれる、完全コンポーネント指向な実装です。

【Godot 4】一瞬の「間」で気持ちよく!「ImpactShake」コンポーネント

今回のコンポーネントは、

  • 攻撃がヒットしたタイミングで呼び出すと
  • Engine.time_scale を一瞬だけ下げて
  • 自動で元に戻してくれる

という、「ヒットストップ専用マネージャ」です。
コンポーネントなので、プレイヤーでも敵でも、ボスでも、どのシーンにも自由にアタッチして使えます。

フルコード: ImpactShake.gd


extends Node
class_name ImpactShake
## 攻撃ヒット時などに「ヒットストップ(時間スケール低下)」を発生させるコンポーネント。
## 任意のノードにアタッチして、攻撃ヒット時に `trigger()` を呼ぶだけでOKです。

## --- 設定パラメータ ---------------------------------------------------------

@export_range(0.01, 1.0, 0.01)
var slow_scale: float = 0.2:
	## ヒットストップ中に設定する Engine.time_scale の値。
	## 0.2 なら「全体が 20% の速度」になります。
	set(value):
		slow_scale = clamp(value, 0.01, 1.0)

@export_range(0.01, 0.5, 0.01)
var duration: float = 0.08:
	## ヒットストップの長さ(秒)。
	## 短いほど「キレが良い」、長いほど「重い一撃」になります。
	set(value):
		duration = max(value, 0.01)

@export_range(0.0, 0.5, 0.01)
var recover_time: float = 0.05:
	## ヒットストップ終了後、元の time_scale に戻るまでの補間時間(秒)。
	## 0 にすると一瞬で元に戻ります(カチッとした印象)。
	## 少し時間を取ると「ヌルッ」と戻る感じになります。
	set(value):
		recover_time = max(value, 0.0)

@export var allow_stack: bool = true:
	## ヒットストップの「重ねがけ」を許可するかどうか。
	## true: すでにヒットストップ中でも、より強い(slow_scale が小さい or duration が長い)要求を上書き。
	## false: すでにヒットストップ中なら、新しい要求は無視します。
	pass

@export var debug_print: bool = false:
	## デバッグ用。true にすると、ヒットストップ開始/終了時にログを出します。
	pass

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

var _base_time_scale: float = 1.0        ## ヒットストップ前の time_scale を保存
var _is_active: bool = false            ## 現在ヒットストップ中かどうか
var _tween: Tween                       ## time_scale を戻すための Tween
var _current_end_time: float = 0.0      ## 現在予定されている「完全復帰時刻」

func _ready() -> void:
	## シーン開始時に現在の time_scale を記録しておく
	_base_time_scale = Engine.time_scale


## ヒットストップを発生させるメインAPI
## 例: `impact_shake.trigger()` または `impact_shake.trigger(0.1, 0.15)`
func trigger(
		slow_scale_override: float = -1.0,
		duration_override: float = -1.0,
		recover_time_override: float = -1.0
	) -> void:
	## オプション引数が指定されていれば上書き
	var target_slow_scale := slow_scale if slow_scale_override <= 0.0 else clamp(slow_scale_override, 0.01, 1.0)
	var target_duration := duration if duration_override <= 0.0 else max(duration_override, 0.01)
	var target_recover_time := recover_time if recover_time_override < 0.0 else max(recover_time_override, 0.0)

	var now := Time.get_ticks_msec() / 1000.0

	# すでにヒットストップ中の場合の挙動
	if _is_active:
		if not allow_stack:
			# スタック禁止なら新しい要求は無視
			if debug_print:
				print("[ImpactShake] already active, new trigger ignored.")
			return

		# スタック許可の場合、「より強い」ヒットストップなら上書きする戦略。
		# ・slow_scale がより小さい(= より遅い)か
		# ・完全復帰予定時刻が今より早い(= 新しい方が長い)なら更新してよい、とする。
		if target_slow_scale >= Engine.time_scale and _current_end_time >= now + target_duration + target_recover_time:
			# 既存の方が強い/長いので無視
			if debug_print:
				print("[ImpactShake] already active with stronger/longer effect, new trigger ignored.")
			return

	# ここから実際にヒットストップを適用
	_apply_hit_stop(target_slow_scale, target_duration, target_recover_time)


func _apply_hit_stop(target_slow_scale: float, target_duration: float, target_recover_time: float) -> void:
	_is_active = true
	_base_time_scale = Engine.time_scale

	if _tween and _tween.is_running():
		_tween.kill()

	# すぐに time_scale を下げる
	Engine.time_scale = target_slow_scale

	var now := Time.get_ticks_msec() / 1000.0
	_current_end_time = now + target_duration + target_recover_time

	if debug_print:
		print("[ImpactShake] start: scale=", target_slow_scale,
			" duration=", target_duration, " recover=", target_recover_time)

	# duration 経過後に元スケールへ戻す Tween を開始
	# ヒットストップ中は完全に低速にして、終わってから補間で戻すイメージ
	_tween = create_tween()
	_tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
	_tween.set_trans(Tween.TRANS_QUAD)
	_tween.set_ease(Tween.EASE_OUT)

	# duration 経過後に補間開始
	_tween.tween_interval(target_duration)

	if target_recover_time > 0.0:
		# time_scale を base_time_scale までスムーズに戻す
		_tween.tween_property(Engine, "time_scale", _base_time_scale, target_recover_time)
	else:
		# recover_time が 0 の場合は一瞬で戻す
		_tween.tween_callback(Callable(self, "_reset_time_scale_immediately"))

	# 完了時のコールバック
	_tween.tween_callback(Callable(self, "_on_tween_completed"))


func _reset_time_scale_immediately() -> void:
	Engine.time_scale = _base_time_scale


func _on_tween_completed() -> void:
	_is_active = false
	if debug_print:
		print("[ImpactShake] end: time_scale=", Engine.time_scale)


## 明示的にヒットストップをキャンセルしたい場合に呼ぶ
## (例: ポーズメニューを開いたときに強制的に通常速度に戻すなど)
func cancel() -> void:
	if _tween and _tween.is_running():
		_tween.kill()
	_tween = null
	_is_active = false
	Engine.time_scale = _base_time_scale
	if debug_print:
		print("[ImpactShake] canceled, time_scale reset to ", _base_time_scale)

使い方の手順

ここからは、実際にゲームシーンへ組み込む手順を見ていきましょう。

手順①: スクリプトを用意する

  1. 上記のコードを ImpactShake.gd という名前で保存します(res://components/ImpactShake.gd など)。
  2. Godotエディタを再読み込みすると、ノード追加ダイアログImpactShake が検索できるようになります。

手順②: プレイヤー(または敵)シーンにコンポーネントとしてアタッチ

例として、2Dアクションのプレイヤーに付けるケースを考えます。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AttackArea (Area2D)
 │    └── CollisionShape2D
 └── ImpactShake (Node)
  • Player シーンを開き、ルート(CharacterBody2D)配下に Node を追加します。
  • その Node に ImpactShake.gd をアタッチするか、「ノードを追加」→ ImpactShake を選びます。
  • インスペクタで以下を調整します:
    • slow_scale: 0.1 ~ 0.3 あたりが使いやすい
    • duration: 0.05 ~ 0.12 くらいで調整
    • recover_time: 0.0 ~ 0.08(0だとカチッと、0.05くらいだと少しヌルっと)

手順③: 攻撃ヒット時に trigger() を呼ぶ

次に、攻撃が当たったタイミングで ImpactShake.trigger() を呼び出します。
典型的には、Area2Dbody_entered / area_entered シグナルで呼ぶ形ですね。


# Player.gd (例)
extends CharacterBody2D

@onready var impact_shake: ImpactShake = $ImpactShake
@onready var attack_area: Area2D = $AttackArea

func _ready() -> void:
	# 攻撃判定のシグナル接続(エディタから接続してもOK)
	attack_area.body_entered.connect(_on_attack_area_body_entered)


func _on_attack_area_body_entered(body: Node) -> void:
	# ここでダメージ処理やノックバックなどを行う
	if body.has_method("take_damage"):
		body.take_damage(10)

	# ヒットストップを発生させる
	# パラメータを省略すればコンポーネント側の設定値を使用します
	impact_shake.trigger()

	# 特定の攻撃だけ「重い」感じにしたいなら、上書きして呼んでもOK:
	# impact_shake.trigger(0.1, 0.15, 0.05)

これだけで、攻撃がヒットした瞬間に一瞬ゲーム全体がスローになり、かなり気持ちのいい打撃感が出ます。

手順④: 敵やボスにも同じコンポーネントを再利用

コンポーネント指向の良いところは、「同じ仕組みを別シーンでもそのまま使える」点です。
敵キャラにも同じように ImpactShake を仕込んでおけば、

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── HurtBox (Area2D)
 │    └── CollisionShape2D
 └── ImpactShake (Node)

敵がプレイヤーを殴ったときにも、まったく同じ API でヒットストップをかけられます。


# Enemy.gd (例)
extends CharacterBody2D

@onready var impact_shake: ImpactShake = $ImpactShake
@onready var attack_area: Area2D = $AttackArea

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


func _on_attack_area_body_entered(body: Node) -> void:
	if body.name == "Player":
		if body.has_method("take_damage"):
			body.take_damage(5)

		# 敵の攻撃はプレイヤー攻撃より少し弱めのヒットストップにする例
		impact_shake.trigger(0.4, 0.05, 0.03)

プレイヤーと敵で別々のクラス継承をしていても、コンポーネントをアタッチしてシグナルから呼ぶだけなので、ロジックを共有しやすくなります。

メリットと応用

ImpactShake をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • シーン構造がスッキリする
    プレイヤーや敵のメインスクリプトに Engine.time_scale 管理ロジックを書かなくて済みます。
    「ヒットストップの調整」は ImpactShake ノードのインスペクタを見るだけでOKなので、どこで何をしているかが一目瞭然です。
  • 使い回しがしやすい
    プレイヤー、敵、動くギミック、ボス演出など、どのシーンにも同じコンポーネントを貼って使い回せます。
    「このボスだけはヒットストップ長めにしたい」といった要望も、シーン単位でパラメータを変えるだけで実現できます。
  • 継承ツリーに依存しない
    BaseFighter を継承したやつだけヒットストップできる」みたいな制約がなくなります。
    どんなクラス構造でも、ノードとしてアタッチしてシグナルから呼ぶだけなので、後からでも簡単に導入できます。
  • 時間制御の責務が一箇所にまとまる
    Engine.time_scale をいじる処理を1箇所に閉じ込められるので、
    「ポーズメニュー」「スローモーション演出」「ヒットストップ」などが競合したときも、どこを見ればいいか明確になります。

改造案: 「クリティカルヒットだけ強いヒットストップ」にする

例えば、プレイヤー攻撃の中でも「クリティカルヒット」のときだけ、ヒットストップを強めにしたい場合は、trigger() をラップするヘルパー関数を追加しても良いですね。


# Player.gd の一部に追加する例

func apply_hit_stop(is_critical: bool) -> void:
	if is_critical:
		# クリティカルは重く長く
		impact_shake.trigger(0.1, 0.16, 0.06)
	else:
		# 通常ヒットは控えめ
		impact_shake.trigger(0.25, 0.08, 0.04)

こんな感じで、「ヒットストップのチューニングロジック」もプレイヤー側に少しだけ持たせつつ、
実際の時間制御はすべて ImpactShake コンポーネントに任せる、という分業がきれいですね。

継承で巨大なプレイヤークラスを作り込むよりも、こうした小さなコンポーネントを組み合わせていく方が、
後からの調整や機能追加が圧倒的に楽になるので、ぜひ試してみてください。