UIボタンや拾えるアイテムを「目立たせたい」とき、ついAnimationPlayer をベタ貼りして、シーンごとにアニメーションを作っていませんか?
あるいは、ButtonSprite2Dを継承したカスタムシーンを量産して、「あのボタンだけアニメーションが違う…どれが本物のプレハブだっけ?」というカオスに陥りがちですよね。

Godot 4 では、依然として「ノード継承+深いシーン階層」で何でも解決しがちですが、
脱・初心者を目指すなら、ここは一歩進んでコンポーネント指向(継承より合成)に寄せていきたいところです。

そこで今回は、「親ノードの scale を定期的に拡大縮小させて注目させる」だけに責務を絞ったコンポーネント
ScalePulse を作っていきます。

このコンポーネントをポン付けするだけで、UIボタンでもアイテムでも、
「ちょっとだけふわふわ膨らむ」注目アニメーションを、アニメーションリソースなしで実現できます。

【Godot 4】ふわっと目立たせるパルスアニメ!「ScalePulse」コンポーネント

まずはフルコードから。res://components/scale_pulse.gd のような場所に置いて、
どのシーンからも使い回せるようにしておきましょう。


extends Node
class_name ScalePulse
"""
ScalePulse コンポーネント
親ノードの scale を一定のリズムで拡大縮小させて「注目させる」ためのコンポーネント。

想定用途:
- UIボタンをふわっと脈打つように目立たせる
- 拾えるアイテム(コインやポーションなど)を見つけやすくする
- インタラクト可能なオブジェクトをプレイヤーにアピールする

親ノードのタイプ:
- Control / Node2D / Sprite2D / TextureRect など、scale プロパティを持つもの
"""

@export_category("Pulse Settings")
@export var enabled: bool = true:
	set(value):
		enabled = value
		_set_process_state()

@export var pulse_scale: Vector2 = Vector2(1.2, 1.2)
## 拡大時のスケール倍率。
## 例: (1.2, 1.2) なら、元の 120% までふわっと拡大します。

@export_range(0.05, 5.0, 0.01, "or_greater") var pulse_duration: float = 0.6
## 1 回の「拡大→縮小」にかかる時間(秒)。
## 小さいほど速くパルスし、大きいほどゆったりとした動きになります。

@export_range(0.0, 5.0, 0.01, "or_greater") var delay_before_start: float = 0.0
## アクティブ化してからパルス開始までの遅延(秒)。
## 画面に出てすぐ動くと煩い場合に、少し遅らせるのに便利です。

@export_range(0.0, 1.0, 0.01) var easing: float = 0.3
## 補間の「なめらかさ」を決めるイージング係数。
## 0 に近いと直線的、1 に近いと加減速が強くなります。

@export_category("Behavior")
@export var randomize_phase: bool = true
## シーン内に同じ ScalePulse が複数あるとき、
## それぞれ開始タイミングをランダムにずらして「同時に脈打たない」ようにするフラグ。

@export var affect_children: bool = false
## true にすると、親ノードだけでなく子孫ノード全体をまとめてスケーリングします。
## 通常は false 推奨(親ノードの scale だけで十分なことが多い)。

@export_category("Debug")
@export var preview_in_editor: bool = false
## エディタ上でもプレビューしたい場合に ON。
## ※あまり多用するとエディタがうるさくなるので注意。

# 内部状態
var _base_scale: Vector2
var _time: float = 0.0
var _started: bool = false
var _random_offset: float = 0.0

func _ready() -> void:
	# 親ノードが必要(scale を持つノードを想定)
	if get_parent() == null:
		push_warning("ScalePulse: 親ノードがありません。scale を持つノードの子として配置してください。")
		set_process(false)
		set_physics_process(false)
		return

	# 親の初期スケールを保存
	_base_scale = get_parent().scale

	# ランダムフェーズずらし
	if randomize_phase:
		_random_offset = randf() * pulse_duration
	else:
		_random_offset = 0.0

	_time = 0.0
	_started = false
	_set_process_state()

func _notification(what: int) -> void:
	# エディタ上での挙動制御
	if what == NOTIFICATION_EDITOR_ENTER:
		_set_process_state()
	elif what == NOTIFICATION_EDITOR_EXIT:
		# エディタから抜けるときは元のスケールに戻しておく
		_reset_scale()
		set_process(false)

func _set_process_state() -> void:
	# 実行中 or エディタプレビュー時のみ処理を回す
	var should_process := enabled and (Engine.is_editor_hint() == false or preview_in_editor)
	set_process(should_process)
	# 物理フレームは不要なので process のみ

func _process(delta: float) -> void:
	if not enabled:
		return

	_time += delta

	# 開始ディレイを考慮
	if not _started:
		if _time >= delay_before_start:
			_started = true
			# 遅延を引いて、ランダム位相を加える
			_time = (_time - delay_before_start) + _random_offset
		else:
			# ディレイ中はスケールを初期値に保つ
			_apply_scale(_base_scale)
			return

	# パルスの位相を 0..1 に正規化
	var phase := fmod(_time, pulse_duration) / max(pulse_duration, 0.0001)

	# 0..1..0 の三角波を生成(拡大→縮小)
	var tri := 1.0 - abs(phase * 2.0 - 1.0)  # 0 → 1 → 0

	# イージング(少し丸みを持たせる)
	var eased := _ease_in_out(tri, easing)

	# 現在のスケールを計算
	var current_scale := _base_scale.lerp(pulse_scale * _base_scale, eased)
	_apply_scale(current_scale)

func _apply_scale(new_scale: Vector2) -> void:
	var parent := get_parent()
	if parent == null:
		return

	if affect_children:
		# 子孫ごとまとめてスケーリングしたい場合
		parent.scale = new_scale
	else:
		# 通常は親ノードの scale だけ調整すれば OK
		parent.scale = new_scale

func _ease_in_out(t: float, k: float) -> float:
	"""
	0..1 の t を、簡易的なイージングで丸める。
	k は 0..1 の係数で、0 に近いほど線形、1 に近いほど強いカーブ。
	"""
	if k <= 0.0:
		return t
	if k >= 1.0:
		# ほぼ S 字カーブ
		return t * t * (3.0 - 2.0 * t)
	# k をブレンド係数として線形補間
	var smooth := t * t * (3.0 - 2.0 * t)
	return lerp(t, smooth, k)

func _reset_scale() -> void:
	var parent := get_parent()
	if parent:
		parent.scale = _base_scale

func reset_to_base_scale() -> void:
	"""
	外部から呼び出して、スケールを初期値に戻したいときに使います。
	例: ボタンが押されたらアニメーションを止めて元のサイズに戻す、など。
	"""
	_time = 0.0
	_started = false
	_reset_scale()

func stop_pulse() -> void:
	"""
	パルスアニメーションを停止し、現在のスケールを維持します。
	"""
	enabled = false
	set_process(false)

func start_pulse() -> void:
	"""
	パルスアニメーションを再開します。
	"""
	_time = 0.0
	_started = false
	enabled = true
	_set_process_state()

使い方の手順

ここからは、実際のシーンに ScalePulse を組み込む手順を見ていきましょう。
UIボタンとフィールドアイテムの 2 パターンで例を出します。

手順①: スクリプトをプロジェクトに追加

  1. res://components/ フォルダを作成(なければ)
  2. その中に scale_pulse.gd を作成し、上記コードをコピペ
  3. 保存すると ScalePulse がクラスとして認識され、ノード追加ダイアログからも検索できるようになります

手順②: UIボタンにパルスを付ける例

例えば、「決定ボタン」をふわっと目立たせたいケース。

MainMenu (Control)
 ├── Panel
 │    └── StartButton (Button)
 │          └── ScalePulse (Node)
 └── その他UI...
  1. StartButton を選択し、その子として + ボタン → ノード追加 を開く
  2. ScalePulse を検索して追加
  3. ScalePulse ノードを選択し、インスペクタで以下のように設定
    • pulse_scale = Vector2(1.1, 1.1)(UIなので控えめに)
    • pulse_duration = 0.8(ゆっくりとした脈打ち)
    • delay_before_start = 0.2(フェードイン後に動き始めるイメージ)
    • randomize_phase = false(ボタンは単体なので不要)

これだけで、StartButton がふわっと拡大縮小するようになります。
アニメーションリソースも、AnimationPlayer も不要です。

手順③: フィールド上のアイテムにパルスを付ける例

次に、2D アクションゲームの「コイン」アイテムを目立たせる例です。

Coin (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ScalePulse (Node)
  1. Coin シーンを開き、ルート Area2D の子として ScalePulse を追加
  2. ScalePulse を選択し、インスペクタで:
    • pulse_scale = Vector2(1.3, 1.3)(ちょっと大きめに)
    • pulse_duration = 0.5(テンポよく)
    • randomize_phase = true(複数コインが並んでも同じタイミングにならない)
    • affect_children = false(親の scale だけで十分)

この Coin シーンをステージにばらまくだけで、
全てのコインが「それぞれ違うタイミング」でふわふわ光っているように見えます。

手順④: 実行時に ON/OFF する例

「特定のタイミングでだけ目立たせたい」ケースもあります。
例えば、インタラクト可能なオブジェクトを、プレイヤーが近づいたときだけパルスさせるイメージです。

InteractableObject (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ScalePulse (Node)

InteractableObject.gd 側で、以下のように制御できます。


extends Node2D

@onready var pulse: ScalePulse = $ScalePulse

func _ready() -> void:
	# 最初は非アクティブ
	pulse.stop_pulse()
	pulse.reset_to_base_scale()

func on_player_enter_range() -> void:
	# プレイヤーが近づいたらパルス開始
	pulse.start_pulse()

func on_player_exit_range() -> void:
	# 離れたらパルス終了&元に戻す
	pulse.stop_pulse()
	pulse.reset_to_base_scale()

このように、見た目のアテンション制御をコンポーネントに丸投げしておくと、
ゲームロジック側は「いつ ON/OFF するか」だけ考えれば良くなります。

メリットと応用

ScalePulse をコンポーネントとして切り出すメリットを、いくつか挙げてみます。

  • シーン構造がシンプルになる
    UIボタンやアイテムごとに AnimationPlayer を生やす必要がなく、
    「動きのロジック」はすべて ScalePulse に集約されます。
  • 継承地獄からの脱出
    AnimatedButtonPulsingCoin みたいな「動き込みのカスタムシーン」を増やす代わりに、
    ButtonCoin はそのまま、ScalePulse を後付けするだけで機能追加できます。
  • パラメータで挙動を統一・調整しやすい
    「全 UI のパルス速度を 0.6 → 0.8 に変更したい」といった場合、
    コンポーネントのデフォルト値を変えるだけで新規シーンに自動で反映できます。
  • テストがしやすい
    単体の ScalePulse シーンで挙動を確認できるので、
    大きな UI シーンを開かなくても調整・デバッグが可能です。

応用として、例えば「プレイヤーが HP 少ないときだけ UI の HP バーをパルスさせる」など、
ゲームの状態に応じて start_pulse() / stop_pulse() を切り替えると、
かなりリッチなフィードバックが簡単に実装できます。

改造案: マウスホバー中だけパルスさせる UI ボタン

最後に、Button 側から ScalePulse を制御して、
「マウスが乗っている間だけ」ふわっとさせる簡単な改造例を置いておきます。


# HoverPulseButton.gd
extends Button

@onready var pulse: ScalePulse = $ScalePulse

func _ready() -> void:
	# 最初は止めておく
	pulse.stop_pulse()
	pulse.reset_to_base_scale()
	mouse_entered.connect(_on_mouse_entered)
	mouse_exited.connect(_on_mouse_exited)

func _on_mouse_entered() -> void:
	pulse.start_pulse()

func _on_mouse_exited() -> void:
	pulse.stop_pulse()
	pulse.reset_to_base_scale()

このように、「アニメーションの実装」は ScalePulse に閉じ込めておき、
ボタンやアイテムのスクリプトは「いつアニメーションさせるか」だけを知っていれば十分、という構造ですね。

継承より合成で、小さくて再利用しやすいコンポーネントをどんどん増やしていきましょう。