Godot 4 で 2Dキャラを作っていると、「とりあえず待機中はちょっと動かして生っぽくしたい」って場面、多いですよね。
でも、毎回プレイヤーや敵ごとに「ぷるぷる」処理を書いていると…

  • プレイヤーのスクリプトが「移動+攻撃+アニメ+ぷるぷる」で肥大化する
  • 敵キャラにも同じようなコードをコピペして、あとで全体修正が地獄
  • 「この敵だけ揺れ方を変えたい」→ 継承クラスをさらに増やす or 条件分岐まみれ

Godot 標準の「スプライトに直接アニメーションを書く」「プレイヤーのスクリプトに全部まとめる」方式だと、シーンツリーもスクリプトもどんどん太っていくんですよね。
そこで今回は、継承ではなくコンポーネントとしてアタッチするだけで、どのスプライトにもゼリーのようなぷるぷる感を付与できる 「WobbleEffect」コンポーネント を用意しました。

親ノードの種類(プレイヤー、敵、動く床…)には一切依存せず、Sprite2D / AnimatedSprite2D を子に持つノードなら何にでも付けられるようにしてあります。
「ぷるぷる」はコンポーネントに任せて、本体のスクリプトはロジックに集中させましょう。

【Godot 4】ぷるぷるでゼリー感アップ!「WobbleEffect」コンポーネント

以下が、今回のフルコードです。
ポイント

  • 親ノードの子にある Sprite2D / AnimatedSprite2D を自動検出
  • FastNoiseLite を使って時間ベースのノイズを生成
  • 揺れの強さ・速さ・軸・有効フラグなどを @export で調整可能
  • 元の位置・回転・スケールを保存して、ぷるぷるをオフにしても元に戻る

extends Node
class_name WobbleEffect
## ゼリーのような「ぷるぷる感」を Sprite2D / AnimatedSprite2D に付与するコンポーネント
##
## 親ノードの子としてアタッチし、子にある Sprite2D / AnimatedSprite2D を自動検出して揺らします。
## 「待機中だけ有効にする」「ボスだけ揺れを強くする」など、@export で細かく調整できます。

@export_category("Basic")
## エフェクトのオン/オフ。false にすると揺れを停止し、元の Transform に戻します。
@export var enabled: bool = true:
	set(value):
		enabled = value
		if not is_inside_tree():
			return
		if not enabled:
			_reset_sprite_transform()

## ノイズで揺らす対象の Sprite2D / AnimatedSprite2D。
## 未指定の場合、親ノード以下から自動検出します。
@export var target_sprite: Node2D

## ぷるぷるの強さ(ピクセル単位の揺れ幅)。大きいほど大きく揺れます。
@export_range(0.0, 64.0, 0.1, "or_greater")
@export var position_amplitude: float = 4.0

## 回転の揺れ幅(度)。0 にすると回転はしません。
@export_range(0.0, 45.0, 0.1, "or_greater")
@export var rotation_amplitude_deg: float = 3.0

## スケールの揺れ幅。0.1 なら ±0.1 の範囲で伸び縮みします。
@export_range(0.0, 1.0, 0.01, "or_greater")
@export var scale_amplitude: float = 0.1

## ノイズの時間スケール。値を大きくすると揺れが速くなります。
@export_range(0.0, 10.0, 0.01, "or_greater")
@export var speed: float = 2.0

## X軸方向に揺らすかどうか。
@export var wobble_x: bool = true
## Y軸方向に揺らすかどうか。
@export var wobble_y: bool = true

@export_category("Noise")
## ノイズのシード値。敵ごとに違う値にすると揺れ方が変わります。
@export var noise_seed: int = 12345:
	set(value):
		noise_seed = value
		if noise:
			noise.seed = noise_seed

## ノイズの周波数。小さいほどゆっくり・大きなうねりに、 大きいほど細かい揺れになります。
@export_range(0.0001, 2.0, 0.0001, "or_greater")
@export var noise_frequency: float = 0.4:
	set(value):
		noise_frequency = value
		if noise:
			noise.frequency = noise_frequency

## 各軸ごとのオフセット。複数キャラが同じタイミングで揺れないようにずらす用途など。
@export var time_offset_x: float = 0.0
@export var time_offset_y: float = 100.0

@export_category("State")
## 待機中にだけ揺らしたい場合など、外部から制御するためのフラグ。
## 例: プレイヤーが移動中・攻撃中は false、完全停止中だけ true にする。
@export var active_when_idle: bool = true

## 外部から「今は待機中かどうか」を教えるためのフラグ。
## 直接書き換えてもいいし、setter を使ってもOK。
var is_idle: bool = true

# --- 内部用変数 ---

var noise: FastNoiseLite

# 元の Transform を保持しておく
var _base_position: Vector2
var _base_rotation: float
var _base_scale: Vector2

# 初期化が完了したかどうか
var _initialized: bool = false


func _ready() -> void:
	# ターゲットスプライトが指定されていなければ、自動検出を試みる
	if target_sprite == null:
		target_sprite = _find_sprite(get_parent())
	
	if target_sprite == null:
		push_warning("WobbleEffect: Sprite2D / AnimatedSprite2D が見つかりません。エフェクトは無効になります。")
		enabled = false
		return
	
	# 元の Transform を保存
	_base_position = target_sprite.position
	_base_rotation = target_sprite.rotation
	_base_scale = target_sprite.scale
	
	# ノイズの初期化
	noise = FastNoiseLite.new()
	noise.seed = noise_seed
	noise.frequency = noise_frequency
	noise.noise_type = FastNoiseLite.TYPE_SIMPLEX
	
	_initialized = true


func _process(delta: float) -> void:
	if not _initialized:
		return
	
	# 有効フラグや「待機中だけ」設定のチェック
	if not enabled:
		return
	if active_when_idle and not is_idle:
		# 待機中だけ有効にしたい場合、待機していなければ元に戻す
		_reset_sprite_transform()
		return
	
	# 経過時間を取得
	var t := Time.get_ticks_msec() / 1000.0
	
	# ノイズ値は -1.0 ~ 1.0 の範囲になるので、それを使って揺れを生成
	var nx := noise.get_noise_1d((t + time_offset_x) * speed)
	var ny := noise.get_noise_1d((t + time_offset_y) * speed)
	
	# 位置の揺れ
	var offset := Vector2.ZERO
	if wobble_x:
		offset.x = nx * position_amplitude
	if wobble_y:
		offset.y = ny * position_amplitude
	
	# 回転の揺れ(度 → ラジアンに変換)
	var rot_offset := deg_to_rad(nx * rotation_amplitude_deg)
	
	# スケールの揺れ(x, y で別のノイズを使うと面白い)
	var scale_offset := Vector2.ZERO
	scale_offset.x = nx * scale_amplitude
	scale_offset.y = ny * scale_amplitude
	
	# 実際に Transform を適用
	target_sprite.position = _base_position + offset
	target_sprite.rotation = _base_rotation + rot_offset
	target_sprite.scale = _base_scale + scale_offset


func _exit_tree() -> void:
	# ノードがツリーから抜けるときは Transform を戻しておく
	if _initialized and target_sprite:
		_reset_sprite_transform()


## Sprite2D / AnimatedSprite2D を親ノード以下から探すヘルパー関数
func _find_sprite(root: Node) -> Node2D:
	if root is Sprite2D or root is AnimatedSprite2D:
		return root as Node2D
	
	for child in root.get_children():
		if child is Node:
			var found := _find_sprite(child)
			if found:
				return found
	return null


## ターゲットスプライトの Transform を元に戻す
func _reset_sprite_transform() -> void:
	if target_sprite == null:
		return
	target_sprite.position = _base_position
	target_sprite.rotation = _base_rotation
	target_sprite.scale = _base_scale


## 外部から待機状態を更新したいときに呼ぶユーティリティ
func set_idle(value: bool) -> void:
	is_idle = value
	if not is_idle:
		# 待機から外れた瞬間に Transform をリセット
		_reset_sprite_transform()

使い方の手順

例1: プレイヤーキャラをぷるぷるさせる
ベーシックな構成はこんな感じです。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── WobbleEffect (Node)

手順①: コンポーネントスクリプトを用意

  • 上記の WobbleEffect.gd を新規スクリプトとして保存します(例: res://components/WobbleEffect.gd)。
  • Godot エディタを再読み込みすると、ノード追加ダイアログの「スクリプト」タブから WobbleEffect を選べるようになります。

手順②: プレイヤーシーンにアタッチ

  • プレイヤーシーン(Player.tscn)を開きます。
  • ルートの CharacterBody2D を選択して、「子ノードを追加」→「スクリプト」タブ→ WobbleEffect を追加。
  • target_sprite を空欄のままにしておけば、親以下から自動で Sprite2D を探してくれます。

手順③: 待機中だけぷるぷるさせる

プレイヤーのスクリプト側で、移動入力がない時だけ is_idle = true にする構成にしましょう。


# Player.gd (例)
extends CharacterBody2D

@onready var wobble: WobbleEffect = $WobbleEffect

const SPEED := 200.0

func _physics_process(delta: float) -> void:
	var input_vector := Vector2.ZERO
	input_vector.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
	input_vector.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
	
	if input_vector.length() > 0.0:
		input_vector = input_vector.normalized()
	
	velocity = input_vector * SPEED
	move_and_slide()
	
	# 入力がないときだけ待機状態にする
	var now_idle := input_vector == Vector2.ZERO and velocity.length() < 0.1
	wobble.set_idle(now_idle)

これで、完全停止しているときだけゼリーのようにぷるぷる、移動や入力がある間はピタッと止まる、という動きになります。

手順④: 敵やオブジェクトにもコピペなしで再利用

例えば敵キャラ:

SlimeEnemy (CharacterBody2D)
 ├── AnimatedSprite2D
 ├── CollisionShape2D
 └── WobbleEffect (Node)
  • 同じく WobbleEffect を子ノードとして追加。
  • 敵のスクリプトで、「プレイヤーを見失ったときだけ is_idle = true」などにすれば、待機中にぷるぷるするスライムが完成です。
  • noise_seed を敵ごとに変えれば、全員違う揺れ方にできます。

さらに、動く足場にも:

JellyPlatform (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── WobbleEffect (Node)
  • active_when_idle = false にしておくと、常にぷるぷるし続ける足場になります。
  • 「踏まれたときだけ強く揺れる」などは、後述の改造案で簡単に足せます。

メリットと応用

  • シーン構造がシンプル:プレイヤーや敵のスクリプトに揺れ処理を書かないので、本体はロジックだけに集中できます。
  • 完全に独立したコンポーネントCharacterBody2D でも StaticBody2D でも、Sprite2D さえあれば同じコンポーネントをポン付けできます。
  • 継承地獄を回避BaseEnemyWobbleEnemyFlyingWobbleEnemy… みたいなクラス増殖を防げます。「揺れ」は WobbleEffect に閉じ込めておけばOK。
  • レベルデザインが楽:シーンエディタ上で noise_seedposition_amplitude をいじるだけで、個々のキャラの揺れ方を直感的に調整できます。

応用としては:

  • ボスだけ rotation_amplitude_deg を大きくして、ぐにゃぐにゃ感を強調
  • 水面やゼリー床など、「生きているオブジェクト」演出に使う
  • プレイヤーの HP が少ないときだけ揺れを激しくして、不安定さを演出

最後に、「踏まれた瞬間だけ一時的に揺れを強くする」ための簡単な改造案を載せておきます。
WobbleEffect に次の関数を追加すると、外部から「バウンド」をトリガーできます。


# WobbleEffect.gd に追記する改造例
var _burst_timer: float = 0.0
var _burst_duration: float = 0.2
var _burst_multiplier: float = 2.5

func trigger_burst(duration: float = 0.2, multiplier: float = 2.5) -> void:
	# 一時的に揺れを強くするトリガー
	_burst_duration = duration
	_burst_multiplier = multiplier
	_burst_timer = _burst_duration

func _process(delta: float) -> void:
	if not _initialized:
		return
	
	if not enabled:
		return
	if active_when_idle and not is_idle:
		_reset_sprite_transform()
		return
	
	# バースト中はタイマーを減らしつつ、振幅を一時的に増やす
	var amp_mul := 1.0
	if _burst_timer > 0.0:
		_burst_timer -= delta
		amp_mul = _burst_multiplier
	
	var t := Time.get_ticks_msec() / 1000.0
	var nx := noise.get_noise_1d((t + time_offset_x) * speed)
	var ny := noise.get_noise_1d((t + time_offset_y) * speed)
	
	var offset := Vector2.ZERO
	if wobble_x:
		offset.x = nx * position_amplitude * amp_mul
	if wobble_y:
		offset.y = ny * position_amplitude * amp_mul
	
	var rot_offset := deg_to_rad(nx * rotation_amplitude_deg * amp_mul)
	var scale_offset := Vector2.ZERO
	scale_offset.x = nx * scale_amplitude * amp_mul
	scale_offset.y = ny * scale_amplitude * amp_mul
	
	target_sprite.position = _base_position + offset
	target_sprite.rotation = _base_rotation + rot_offset
	target_sprite.scale = _base_scale + scale_offset

これを使えば、例えば「プレイヤーが足場に着地したとき」に


# JellyPlatform.gd 側から
@onready var wobble: WobbleEffect = $WobbleEffect

func on_player_landed() -> void:
	wobble.trigger_burst(0.3, 3.0)

のように呼び出すだけで、着地の瞬間だけぐにゃっと大きく揺れる足場が簡単に実現できます。
このように、ぷるぷるロジックをすべてコンポーネントに閉じ込めておくと、演出のアイデアを思いついた瞬間にサクッと試せるのが良いところですね。