Godot 4でアクションゲームを作っていると、「攻撃が当たった感」をどう演出するかはかなり重要ですよね。
多くの人は最初、アニメーションを凝ったり、SEを強くしたり、画面シェイクを足したりしますが、一番手軽で効果が高いのが「ヒットストップ(時間を一瞬だけ遅くする)」です。
ただし、素直に実装しようとすると、
- 各プレイヤーや敵のスクリプトごとに
Engine.time_scaleをいじる処理を書く - 複数の攻撃が同時にヒットしたとき、どのスクリプトが
time_scaleを戻すのか管理が面倒 - 「この敵はヒットストップ弱め」「この攻撃は長め」などの調整がスクリプトに埋もれていく
といった「管理地獄」に陥りがちです。
しかも、Godot標準の作り方だと、Player や Enemy のスクリプトがどんどん肥大化していき、継承ツリーもノード階層も深くなりがちなんですよね。
そこでこの記事では、どのノードにもポン付けできる「ヒットストップ専用コンポーネント」として、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)
使い方の手順
ここからは、実際にゲームシーンへ組み込む手順を見ていきましょう。
手順①: スクリプトを用意する
- 上記のコードを
ImpactShake.gdという名前で保存します(res://components/ImpactShake.gdなど)。 - 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() を呼び出します。
典型的には、Area2D の body_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 コンポーネントに任せる、という分業がきれいですね。
継承で巨大なプレイヤークラスを作り込むよりも、こうした小さなコンポーネントを組み合わせていく方が、
後からの調整や機能追加が圧倒的に楽になるので、ぜひ試してみてください。
