Godot 4でアクションゲームやシューティングを作っていると、ダメージを受けたときや爆発が起きたときに「画面を揺らしたい!」という場面がよくありますよね。
多くの人は Camera2D / Camera3D を継承したカスタムクラスを作って、その中で offset をいじったり、アニメーションプレイヤーを仕込んだりします。
ただ、このやり方にはいくつか「面倒ポイント」があります。
- カメラのロジック(追従、ズーム、リミット設定など)と「画面揺れ」のロジックが1クラスにベタッとくっついてしまう
- 別のカメラでも同じ揺れを使いたいときに、継承チェーンを増やすか、コピペが発生しがち
- シーンツリーを眺めても「このカメラは揺れるのか?」がパッと見で分からない
そこで今回は、「カメラにアタッチするだけ」で使える独立コンポーネントとして、画面振動を実装してみましょう。
カメラは素の Camera2D / Camera3D のままにしておき、揺れの挙動は ScreenShake コンポーネントに全部お任せします。
まさに「継承より合成(Composition)」な設計ですね。
【Godot 4】ノイズで気持ちよく揺らす!「ScreenShake」コンポーネント
今回作る ScreenShake は、こんな特徴を持つコンポーネントです。
- Camera2D / Camera3D の
offsetをノイズ関数で揺らす - 揺れの「強さ」「減衰時間」「周波数」をエディタから調整可能
start_shake()を呼ぶだけで発動できるシンプルAPI- 複数のカメラに好きなだけアタッチできる(コンポーネント方式)
2Dでも3Dでも使えるように、ターゲットノードを Node3D / Node2D のどちらでも扱えるようにしつつ、
内部的には「画面揺れ用のオフセット」を足し引きするだけ、というシンプルな実装にします。
フルコード:ScreenShake.gd
extends Node
class_name ScreenShake
## 画面振動コンポーネント。
## カメラ(Camera2D / Camera3D など)の子ノードとしてアタッチして使います。
##
## 【使い方の流れ】
## 1. カメラの子として ScreenShake を置く
## 2. @export で揺れの強さ・長さなどを調整
## 3. ダメージや爆発時に start_shake() を呼ぶ
@export_group("基本設定")
## 揺れのデフォルト強度(ピクセル or ユニット)。
## start_shake() の引数で上書きすることもできます。
@export var default_amplitude: float = 16.0
## 揺れが完全に収束するまでのおおよその時間(秒)。
@export var default_duration: float = 0.4
## ノイズの周波数。値が大きいほど「ブルブル細かく」揺れます。
@export_range(0.1, 50.0, 0.1)
@export var noise_frequency: float = 12.0
@export_group("方向・モード")
## 2Dカメラか3Dカメラかを明示しておくと、補完処理が少し分かりやすくなります。
## 実際には Node2D / Node3D のどちらにも対応します。
@export_enum("Auto", "2D", "3D")
var mode: String = "Auto"
## X軸方向に揺らすかどうか
@export var shake_x: bool = true
## Y軸方向に揺らすかどうか
@export var shake_y: bool = true
## Z軸方向に揺らすかどうか(3Dカメラ向け)
@export var shake_z: bool = false
@export_group("ターゲット")
## 揺らしたいノード。通常は Camera2D / Camera3D を指定します。
## 未指定の場合は親ノードを自動的にターゲットとします。
@export var target_node: NodePath
## 現在揺れているかどうか
var _is_shaking: bool = false
## 現在の揺れの残り時間
var _time_left: float = 0.0
## 現在の揺れの合計時間(減衰計算用)
var _total_duration: float = 0.0
## 現在の揺れの最大振幅
var _amplitude: float = 0.0
## ノイズ生成用の乱数と時間
var _noise_seed_x: float
var _noise_seed_y: float
var _noise_seed_z: float
var _time: float = 0.0
## 元のオフセットを保持しておく(揺れ終了後に戻す)
var _base_offset: Vector3 = Vector3.ZERO
## 現在適用している「揺れ分」のオフセット
var _shake_offset: Vector3 = Vector3.ZERO
## 実際にオフセットを操作する対象
var _target: Node = null
## 2Dか3Dかを解決したモード
var _resolved_mode_3d: bool = false
var _resolved_mode_2d: bool = false
func _ready() -> void:
_resolve_target()
_resolve_mode()
_capture_base_offset()
_reset_noise_seed()
func _process(delta: float) -> void:
if not _is_shaking:
return
_time += delta
_time_left -= delta
if _time_left <= 0.0:
_is_shaking = false
_time_left = 0.0
_apply_shake(Vector3.ZERO)
return
# 0.0 ~ 1.0 の減衰係数(1.0 で開始、0.0 で終了)
var t := 1.0 - (_time_left / _total_duration)
var decay := 1.0 - t # 線形減衰。必要ならイージングも可。
var current_amp := _amplitude * decay
# 時間とシードを使ってノイズっぽい値を生成(-1.0 ~ 1.0)
var x := 0.0
var y := 0.0
var z := 0.0
if shake_x:
x = _pseudo_noise(_noise_seed_x, _time * noise_frequency) * current_amp
if shake_y:
y = _pseudo_noise(_noise_seed_y, _time * noise_frequency) * current_amp
if shake_z:
z = _pseudo_noise(_noise_seed_z, _time * noise_frequency) * current_amp
_apply_shake(Vector3(x, y, z))
# ---------------------------------------------------------
# 公開API
# ---------------------------------------------------------
## 揺れを開始します。
## amplitude: 強さ(未指定なら default_amplitude)
## duration: 長さ(未指定なら default_duration)
func start_shake(amplitude: float = -1.0, duration: float = -1.0) -> void:
if amplitude <= 0.0:
amplitude = default_amplitude
if duration <= 0.0:
duration = default_duration
if duration <= 0.0:
push_warning("ScreenShake: duration が 0 以下なので揺れを開始できません。")
return
_amplitude = amplitude
_total_duration = duration
_time_left = duration
_time = 0.0
_is_shaking = true
_reset_noise_seed()
## 強制的に揺れを停止して、オフセットを元に戻します。
func stop_shake() -> void:
_is_shaking = false
_time_left = 0.0
_apply_shake(Vector3.ZERO)
# ---------------------------------------------------------
# 内部処理
# ---------------------------------------------------------
func _resolve_target() -> void:
if target_node != NodePath():
_target = get_node_or_null(target_node)
else:
_target = get_parent()
if _target == null:
push_warning("ScreenShake: ターゲットノードが見つかりません。親ノードを Camera2D / Camera3D にするか、target_node を指定してください。")
func _resolve_mode() -> void:
if mode == "2D":
_resolved_mode_2d = true
_resolved_mode_3d = false
elif mode == "3D":
_resolved_mode_2d = false
_resolved_mode_3d = true
else:
# Auto: ターゲットの型から推測
if _target is Node3D:
_resolved_mode_3d = true
_resolved_mode_2d = false
else:
_resolved_mode_3d = false
_resolved_mode_2d = true
func _capture_base_offset() -> void:
if _target == null:
return
if _resolved_mode_2d and _target is CanvasItem:
# Camera2D など。2Dは offset(Vector2) を Vector3 に詰めて扱う
if "offset" in _target:
var off2d: Vector2 = _target.offset
_base_offset = Vector3(off2d.x, off2d.y, 0.0)
else:
# offset プロパティがない場合は position を使う fallback
if "position" in _target:
var pos2d: Vector2 = _target.position
_base_offset = Vector3(pos2d.x, pos2d.y, 0.0)
elif _resolved_mode_3d and _target is Node3D:
if "position" in _target:
var pos3d: Vector3 = _target.position
_base_offset = pos3d
else:
# 想定外のターゲットの場合はゼロベース
_base_offset = Vector3.ZERO
func _apply_shake(shake_vec: Vector3) -> void:
if _target == null:
return
_shake_offset = shake_vec
var final_vec := _base_offset + _shake_offset
if _resolved_mode_2d and _target is CanvasItem:
var v2 := Vector2(final_vec.x, final_vec.y)
if "offset" in _target:
_target.offset = v2
elif "position" in _target:
_target.position = v2
elif _resolved_mode_3d and _target is Node3D:
if "position" in _target:
_target.position = final_vec
func _reset_noise_seed() -> void:
var rng := RandomNumberGenerator.new()
rng.randomize()
_noise_seed_x = rng.randi()
_noise_seed_y = rng.randi()
_noise_seed_z = rng.randi()
## 簡易的なノイズ関数。
## 真面目な Perlin/Simplex ほど綺麗ではないですが、
## 画面揺れ用途なら十分「それっぽく」見えます。
func _pseudo_noise(seed: float, t: float) -> float:
# sin と cos を組み合わせて -1.0 ~ 1.0 の値を返す
return sin(seed + t * 1.3) * 0.6 + cos(seed * 0.7 + t * 0.9) * 0.4
使い方の手順
ここからは、実際にプロジェクトに組み込む手順を具体的に見ていきましょう。
① スクリプトをプロジェクトに追加する
- 上記コードを
ScreenShake.gdという名前で保存します(パスはres://addons/以下でも、res://scripts/以下でもOK)。 - Godotエディタで再読み込みされると、
class_name ScreenShakeによって「ScreenShake」がスクリプト一覧に出てくるはずです。
② カメラにコンポーネントとしてアタッチする
2Dのプレイヤーシーンを例にします。
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── Camera2D
└── ScreenShake (Node)
Camera2Dの子としてNodeを追加します。- その
NodeにScreenShake.gdをアタッチします。 - インスペクタで
ScreenShakeのパラメータを調整します。- default_amplitude: 画面の揺れ幅(例: 12~24)
- default_duration: 揺れの長さ(例: 0.2~0.5秒)
- noise_frequency: 揺れの細かさ(大きいほどブルブルする)
- mode: 2Dゲームなら「Auto」か「2D」でOK
3Dの場合も同様です。
Player3D (CharacterBody3D)
├── MeshInstance3D
├── CollisionShape3D
└── Camera3D
└── ScreenShake (Node)
③ ダメージや爆発イベントから呼び出す
プレイヤーがダメージを受けたときに画面を揺らす例です。
# Player.gd(例)
extends CharacterBody2D
@onready var screen_shake: ScreenShake = $Camera2D/ScreenShake
func take_damage(amount: int) -> void:
# HP処理など
# ...
# 画面揺れを発動
screen_shake.start_shake()
爆発エフェクト側から呼びたい場合は、以下のように「現在のアクティブカメラ」を探してもよいです。
# Explosion.gd(例)
extends Node2D
func _ready() -> void:
_shake_camera()
func _shake_camera() -> void:
# シンプルに現在シーン内の ScreenShake を探す方法(小規模プロジェクト向き)
var shakes := get_tree().get_nodes_in_group("screen_shake_group")
for s in shakes:
if s is ScreenShake:
s.start_shake(24.0, 0.3)
# ※ ScreenShake ノードに "screen_shake_group" グループを付けておく必要があります。
グループを使わず、プレイヤーからシグナルで伝播する方式でもOKです。
ここはプロジェクトの構造に合わせて好きに設計しましょう。
④ ノイズ感と減衰を調整して「気持ちいい揺れ」を探る
最後に、ゲームに合わせてパラメータをチューニングしていきます。
- シビアなアクションゲーム:
default_amplitude = 8~12、default_duration = 0.15~0.25、noise_frequency = 15~20 - ド派手な爆発ゲーム:
default_amplitude = 24~40、default_duration = 0.4~0.6、noise_frequency = 8~12
「強くて短い」揺れはキビキビした印象に、「弱くて長い」揺れは余韻のある印象になります。
ゲームのテンポに合わせて、気持ちいいバランスを探してみましょう。
メリットと応用
この ScreenShake コンポーネントを使うことで、いくつか嬉しいポイントがあります。
- カメラのロジックと揺れロジックが分離される
カメラ追従、ズーム、リミット設定などはカメラ側に任せて、揺れだけを ScreenShake に閉じ込められます。
スクリプトが肥大化しにくく、保守しやすくなります。 - シーン構造から「揺れるカメラ」が一目で分かる
シーンツリーを見たときに、Camera の子に ScreenShake が付いていれば「このカメラは揺れるんだな」とすぐに分かります。
深い継承チェーンを追いかける必要がありません。 - 複数カメラでも使い回しが簡単
ミニマップ用カメラや、演出用のカットシーンカメラなど、必要なカメラに ScreenShake をポンポン付けるだけで同じ機能を再利用できます。 - 将来の拡張も差し替えやすい
もっとリッチなPerlinノイズに変えたい、特定方向だけ強くしたい、といった拡張も ScreenShake 単体をいじればOKです。
改造案:イージング付きの減衰にして「最初ドンッ → すぐ収束」
今は線形減衰ですが、少しだけコードを変えると「最初ドンッと強く、すぐに収束する」ような揺れにできます。
_process() 内の減衰計算部分だけ、例えばこんなふうに差し替えてみましょう。
func _compute_decay(t: float) -> float:
# t: 0.0(開始)~ 1.0(終了)に正規化された時間
# 最初にガツンと来て、すぐに弱まるカーブ(イージングアウト)
var inv := 1.0 - t
return inv * inv # (1 - t)^2
# _process() 内の該当部分を以下のように置き換え
func _process(delta: float) -> void:
if not _is_shaking:
return
_time += delta
_time_left -= delta
if _time_left <= 0.0:
_is_shaking = false
_time_left = 0.0
_apply_shake(Vector3.ZERO)
return
var t := 1.0 - (_time_left / _total_duration)
var decay := _compute_decay(t)
var current_amp := _amplitude * decay
# 以下は元のまま...
このように、「減衰カーブだけを差し替える」「ノイズ関数だけを差し替える」といった改造がしやすいのも、コンポーネントとして分離しているおかげですね。
継承に頼らず、カメラにはカメラの責務、揺れには揺れの責務を持たせて、スッキリしたシーン構造で気持ちよく開発していきましょう。
