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

使い方の手順

ここからは、実際にプロジェクトに組み込む手順を具体的に見ていきましょう。

① スクリプトをプロジェクトに追加する

  1. 上記コードを ScreenShake.gd という名前で保存します(パスは res://addons/ 以下でも、res://scripts/ 以下でもOK)。
  2. Godotエディタで再読み込みされると、class_name ScreenShake によって「ScreenShake」がスクリプト一覧に出てくるはずです。

② カメラにコンポーネントとしてアタッチする

2Dのプレイヤーシーンを例にします。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D
      └── ScreenShake (Node)
  1. Camera2D の子として Node を追加します。
  2. その NodeScreenShake.gd をアタッチします。
  3. インスペクタで 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~12default_duration = 0.15~0.25noise_frequency = 15~20
  • ド派手な爆発ゲーム: default_amplitude = 24~40default_duration = 0.4~0.6noise_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

    # 以下は元のまま...

このように、「減衰カーブだけを差し替える」「ノイズ関数だけを差し替える」といった改造がしやすいのも、コンポーネントとして分離しているおかげですね。
継承に頼らず、カメラにはカメラの責務、揺れには揺れの責務を持たせて、スッキリしたシーン構造で気持ちよく開発していきましょう。