攻撃が当たった瞬間に、ゲーム全体の時間の進みをほんの一瞬だけ遅くするヒットストップを入れると、殴った手応えが出ます。Godot では Engine.time_scale を下げて作ります。
下げるだけだと、ゲーム全体がスローのまま戻らなくなったり、白いフラッシュが数秒残ったりします。time_scale を下げると、戻すためのタイマーやフラッシュを消す処理まで、一緒に遅くなるからです。
この記事では、遅くするものと遅くしてはいけないものを分けて、0.08秒で元へ戻るヒットストップを作る方法を解説します。
この記事でわかること
Engine.time_scaleは、全員に配るdeltaを小さくする- 戻すタイマーは、
time_scaleの影響を受けない実時間で数えるtime_scaleを戻す役目は、倒されても消えないノードに持たせる- フラッシュと揺れの戻りだけを実時間にする
動作確認環境: Godot 4.7
Engine.time_scale と delta
当たった瞬間は、60fpsなら約16ミリ秒の1フレームしか画面に出ません。目はその一瞬で「どれに、今、当たった」を読み取れません。ヒットストップは、その一瞬を数フレームぶん引き伸ばして読ませる演出です。
Godot には時間の流れを変えるものが2つあります。
get_tree().paused = true は、処理そのものを呼ばなくします。ゲーム用のノードは _process() も _physics_process() も来なくなり、完全に凍ります。メニューを開くときの道具 です。
Engine.time_scale = 0.05 は違います。関数は毎フレーム呼ばれ続け、渡ってくる delta(前のフレームから何秒たったか)だけが20分の1になります。移動も、アニメーションの進みも、delta で進んでいるものはすべてゆっくりになります。

ヒットストップに使うのは後者です。0.05 なら見た目にはほぼ止まり、関数は呼ばれ続けるので、戻すための処理や演出をそのまま動かせます。0 にはしません。delta が0になることを想定していない処理まで巻き込むからで、公式も0より大きい値を勧めています。
Engine.time_scale はゲーム全体に効きます。誰かを選んで遅くする仕組みはありません。巻き込まれるのは、delta やゲーム内の時間で進んでいるものすべてです。

この記事で起きるつまずきの多くは、「巻き込まれる」側にあるものが原因です。直し方も同じで、遅くなると困るものだけを、time_scale の影響を受けない実時間で数え直します。
他のエンジンでいうと: Unity の
Time.timeScale、Unreal Engine の Set Global Time Dilation に相当します。
遅くすると困る3つのもの
巻き込まれるもののうち、ヒットストップで困るのは次の3つです。
- 止めるのを終わらせる タイマー。これが遅くなると、0.08秒のつもりが1.6秒になります
- フラッシュの戻り。値を時間をかけて動かす Tween は既定で巻き込まれるので、0.15秒で戻すはずの色が、ストップのぶんだけ長く残ります
- 画面揺れの減衰。
deltaで減らしていると、ストップのあいだ揺れが収まりません
一方、ヒット音は何もしなくても普通の速さで鳴ります。AudioStreamPlayer は Engine.time_scale を見ていません。音まで遅くしたいときだけ、全体なら AudioServer.playback_speed_scale、個々の音なら AudioStreamPlayer の pitch_scale を自分で書き換えます。
CanvasLayer に載せても、time_scale からは外れません。レイヤーが決めるのは描画の順番だけです。Process Mode を Always にしても外れません。こちらはポーズを無視する設定で、time_scale とは関係がありません。
戻すタイマー(ignore_time_scale)
Engine.time_scale = 0.05 にしてから0.08秒待つ、という処理を素直に書くとこうなります。
Engine.time_scale = 0.05
await get_tree().create_timer(0.08).timeout
Engine.time_scale = 1.0
create_timer(0.08) は0.08秒で鳴るタイマーを作り、await はそれが鳴るまで関数の続きを止めておく書き方です。これが思ったとおりに動きません。タイマーも time_scale の影響を受けて進むので、実時間では 0.08 / 0.05 で1.6秒かかります。画面は明らかなスローモーションになり、音だけが普通の速さで流れます。

定数を 0.08 × 0.05 にする手もありますが、倍率を変えるたびに計算し直すことになります。直すのは待ち方のほうです。create_timer() には引数が4つあります。
await get_tree().create_timer(0.08, true, false, true).timeout

4つ目が ignore_time_scale です。true にすると Engine.time_scale を無視して、実時間で0.08秒を数えます。2つ目の process_always はポーズを無視する設定で、こちらだけ true にしても、time_scale の影響は受けたままです。3つ目の process_in_physics は物理フレームで数える設定で、ここでは false のままにします。
time_scale を戻すノード(Autoload)
タイマーを直しても、まだ戻らない場合があります。倒した敵を殴ったときだけ です。
await を書いた関数が敵のスクリプトにあるとします。その敵が queue_free()(フレームの終わりにノードを消す)で解放されると、待っていた関数の続きも片付けられます。タイマー自体は SceneTree(シーンを替えても残る、ゲーム全体の木)が持っているので進み続けます。それでも、Engine.time_scale = 1.0 の行は二度と実行されません。Engine.time_scale はシーンに属さないので、そのままタイトル画面まで遅いままです。

time_scale を戻す役目は、シーンが変わっても消えないノードに持たせます。Autoload に1つ置くのが素直です。
# hit_stop.gd(Autoloadに登録する)
extends Node
var _token := 0
func freeze(duration := 0.08, scale := 0.05) -> void:
_token += 1
var mine := _token # この呼び出しの番号を覚えておく
Engine.time_scale = scale
await get_tree().create_timer(duration, true, false, true).timeout
if mine == _token:
Engine.time_scale = 1.0 # 最後の呼び出しだけが戻す
番号を持たせているのは、連続で殴ったときのためです。1発目の await は、2発目のストップの途中で目を覚まします。そのまま 1.0 を代入すると、2発目の手応えが途中で消えます。

Engine.time_scale = scale と代入しているのも同じ理由です。*= 0.05 にすると、ストップ中に2発目が入ったとき 0.0025 まで落ちます。最後のタイマーが 1.0 に戻すので復帰はしますが、その前の一瞬だけ極端に遅くなります。
フラッシュと揺れを実時間で戻す
ここまでで time_scale は0.08秒で戻ります。それでも、フラッシュと揺れは巻き込まれたままです。
Tween は4.4から、set_ignore_time_scale(true) で time_scale を無視できるようになりました。この設定をしないままだと、ストップのあいだ進みが鈍るぶんだけ色の戻りが長引きます。
sprite.modulate = Color(4.0, 4.0, 4.0) # いったん明るくして
var tw := create_tween()
tw.set_ignore_time_scale(true) # time_scale の影響を受けない
tw.tween_property(sprite, "modulate", Color.WHITE, 0.15)
modulate に 1.0 より大きい値を入れると、元の色を増幅して白へ近づけられます。既定の2Dでは、1を超えた分は白で頭打ちになります。黒い部分は何倍しても黒のままなので、純白で抜きたいときの作り方は modulateの記事 にあります。
delta で自分で減らしている処理は、Tween の設定では外れません。画面揺れの減 衰がこれにあたります。ここは time_scale に左右されない数字を自分で取ります。
Time.get_ticks_usec() は起動からの経過をマイクロ秒で返すもので、Engine.time_scale の影響を受けません。前のフレームとの差分を取れば、実時間の delta になります。実践の shake_camera.gd は、この差分で揺れを減らしています。delta / Engine.time_scale で戻す手もありますが、フレームの途中で倍率が変わると食い違います。

3つの演出は、同じ命中処理からまとめて始めます。開始をずらす必要はありません。ずれるのは終わる時刻のほうです。このあとの実践の値なら、ストップが 0.08 秒、フラッシュが 0.15 秒で終わります。揺れは加える量 0.7 を毎秒 3.0 ずつ減らすので、およそ 0.23 秒です。短いものから順に抜けていきます。
実践:歩いて近づき、届いたフレームで止める
ベルトスクロールの殴り、メトロイドヴァニアの斬撃、見下ろしARPGの打撃——手応えの作り方 はどれも同じです。止まっているものを止めても何も見えないので、動いているものが止まる画面を作ります。
① 置くもの
新しいシーンを1つ作ります。
- ルートに Node2D を置いて Main。
main.gdを付ける - Main の子に CharacterBody2D を置いて Player。位置
(200, 300)。player.gdを付ける - Main の子に Camera2D を置いて ShakeCamera。位置
(576, 324)。shake_camera.gdを付ける。Player より下に置く(ツリーの上から順に処理されるので、殴ったのと同じフレームで揺れ始める) - Player の子に Sprite2D(Texture に
icon.svg)と CollisionShape2D(CircleShape2D・半径32)を置く - Main の子に Node2D を置いて Target。位置
(600, 300)。target.gdを付けて、子に Sprite2D(同上) - Main の子に Node2D を置いて Walker。位置
(800, 300)。walker.gdを付けて、子に Sprite2D(同上) - Main の子に CanvasLayer(カメラが動いても画面の同じ位置に出る層)を置いて HUD。子に Label を2つ、RealLabel を
(16, 16)、GameLabel を(16, 48) - ShakeCamera・Target・Walker・RealLabel・GameLabel は、右クリックから「Access as Unique Name」をオンにする。名前の横に
%が付き、スクリプトから%Targetのように呼べる
Walker は、画面の右側を左右に往復するだけのノードです。殴られる相手でも殴る側でもありません。ゲーム全体の時間が遅くなったことは、この Walker の動きで分かります。
「time_scale を戻すノード(Autoload)」の節の hit_stop.gd は、Autoload に登録します。Project Settings › Globals › Autoload で、名前を HitStop にします。
② 組み立て
player.gd は、近づいてスペースキーで殴ります。命中したら、光らせる・揺らす・止めるを同じ行からまとめて呼びます。
# player.gd
extends CharacterBody2D
const SPEED := 400.0
const REACH := 140.0
@onready var target: Node2D = %Target
@onready var camera: Camera2D = %ShakeCamera
func _physics_process(_delta: float) -> void:
velocity.x = Input.get_axis("ui_left", "ui_right") * SPEED
move_and_slide()
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("ui_accept"):
_swing()
func _swing() -> void:
if not is_instance_valid(target): # 倒して消えた相手は殴らない
return
if global_position.distance_to(target.global_position) > REACH:
return
target.flash() # 光らせる
camera.add_trauma(0.7) # 揺らす
HitStop.freeze(0.08, 0.05) # 止める
# target.gd
extends Node2D
@onready var sprite: Sprite2D = $Sprite2D
var _flash_tween: Tween
func flash() -> void:
if _flash_tween and _flash_tween.is_valid():
_flash_tween.kill() # 前の戻りが残っていると開始の色を奪われる
sprite.modulate = Color(4.0, 4.0, 4.0)
_flash_tween = create_tween()
_flash_tween.set_ignore_time_scale(true)
_flash_tween.tween_property(sprite, "modulate", Color.WHITE, 0.15)
# walker.gd
extends Node2D
const SPEED := 150.0
var _dir := 1.0
func _process(delta: float) -> void:
position.x += _dir * SPEED * delta # delta を使うので time_scale の影響を受ける
if position.x > 950.0 or position.x < 750.0:
_dir = -_dir
shake_camera.gd は、揺れの減衰だけ実時間にした形です。本式の作り方は Camera2Dの記事 にあります。
# shake_camera.gd
extends Camera2D
const MAX_SHAKE := 14.0
const DECAY := 3.0
var _trauma := 0.0
var _last_usec := 0
func _ready() -> void:
_last_usec = Time.get_ticks_usec()
func add_trauma(amount: float) -> void:
_trauma = clampf(_trauma + amount, 0.0, 1.0)
func _process(_delta: float) -> void:
var now := Time.get_ticks_usec()
var real_delta := (now - _last_usec) / 1_000_000.0
_last_usec = now
_trauma = maxf(_trauma - DECAY * real_delta, 0.0)
offset = Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * MAX_SHAKE * _trauma
_trauma は揺れの強さを0〜1で持つ変数です。add_trauma() で足し、実時間で毎秒 DECAY ずつ減らします。
main.gd は、実時間とゲーム内の時間の2つを画面に出すだけです。
# main.gd
extends Node2D
var _game_time := 0.0
var _start_usec := 0
@onready var real_label: Label = %RealLabel
@onready var game_label: Label = %GameLabel
func _ready() -> void:
_start_usec = Time.get_ticks_usec()
func _process(delta: float) -> void:
_game_time += delta # time_scale の影響を受ける
var real := (Time.get_ticks_usec() - _start_usec) / 1_000_000.0
real_label.text = "実時間: %.2f" % real
game_label.text = "ゲーム内: %.2f" % _game_time
③ 動かして確かめる

右矢印キーで Target に近づき、届く距離でスペースキーを押します。当たった瞬間に Target が明るく光り、同じ瞬間に Walker の往復も鈍ります。殴られていない Walker まで動かなくなるのは、ゲーム全体の time_scale が下がったからです。Walker が普通に往復し続けるなら、HitStop.freeze() が呼ばれていません。
HUDの2つの数も見てください。ストップのあいだは実時間だけが進み、ゲーム内の時間はほとんど増えません。復帰したあとは、2つの差がストップのぶん(0.1秒弱)開いたまま増えていきます。明るい白は約0.15秒で戻ります。time_scale が戻ったあとも白が長く残るなら、set_ignore_time_scale(true) が抜けています。
番号の効きめを見るなら、player.gd の HitStop.freeze(0.08, 0.05) を一時的に HitStop.freeze(0.4, 0.05) にしてく ださい。1回目から0.4秒たつ前にもう一度押すと、2回目のストップが最後まで続きます。if mine == _token: を if true: に書き換えて同じことをすると、1回目の待ちが終わった時点で time_scale が戻り、2回目が途中で切り上げられます。
ポイントは2つです。
- 止めるものと、止めないものを先に決める:
Engine.time_scaleは選べません。全体に効いたうえで、外したいものを1つずつ実時間へ戻す形になります。戻すタイマー、演出の戻り、揺れの減衰の3つが定番です time_scaleを戻す役目は、消えないノードが持つ: 殴られた相手に持たせると、倒したときだけ戻らなくなります。awaitの続きは、そのノードが解放された時点で実行されなくなるからです
Target を倒して queue_free() する処理を足しても、HitStop は Autoload にあるので time_scale は戻ります。ここまで確認できたら、Camera2Dの記事 の本式の揺れと、modulateの記事 の白フラッシュに差し替えてみてください。
うまくいかないときは
| 症状 | 原因 | 直し方 |
|---|---|---|
| HUDのゲージや数字のアニメまで、ストップのあいだ止まる | それを動かしている Tween も、既定では time_scale に従う | そのTweenにも set_ignore_time_scale(true) を付ける |
| スロー演出の最中に殴ると、ストップ明けにスローが解ける | freeze() が最後に 1.0 を代入していて、入る前の倍率を覚えていない | スローも HitStop に任せ、戻す先の倍率を HitStop が覚えておく |
| 重い一撃の長いストップの途中に軽い攻撃が当たると、ストップが早く切れる | 最後に呼んだ freeze() の長さで 1.0 に戻る。番号は「最後の呼び出し」しか見ていない | 戻る時刻を覚えておき、それより早く終わる呼び出しでは倍率も番号も変えない |
| 揺れだけ1フレーム遅れて始まる | ShakeCamera が Player より上にあり、カメラの _process() が先に終わってから add_trauma() が呼ばれる | シーンツリーで ShakeCamera を Player より下へ移す |
おまけ:先に知っておくと良いこと
シェーダの TIME は Engine.time_scale の影響を受けます。time_scale を下げると、揺らめきや流れるエフェクトも一緒に遅くなります。実時間で動かしたい部分があるなら、Time.get_ticks_usec() から作った値を毎フレーム uniform(スクリプトからシェーダへ渡す変数)で渡します。
Engine.time_scale は1つしかないので、スロー演出とヒットストップを別々に書くと奪い合います。time_scale を触るのは HitStop だけに決めておきます。
まとめ
time_scaleを下げると delta が小さくなる: 関数は呼ばれ続けるので、ポーズと違って演出も戻す処理も動く。0にはしない- 戻すタイマーは実 時間で数える:
create_timer()の4つ目の引数ignore_time_scaleをtrueに - 戻す責任は Autoload に: 敵のスクリプトで
awaitすると、倒したときだけtime_scaleが戻らない - 連打は番号で守る: 最後の呼び出しだけが
1.0に戻す - 演出の戻りだけ実時間に: フラッシュは
set_ignore_time_scale(true)、揺れの減衰はTime.get_ticks_usec()の差分
いま作っている攻撃は、当たった瞬間に何フレーム止めたいものでしょうか。