Godot 4でアクションゲームを作っていると、プレイヤーや敵キャラに「スタン状態」をつけたくなること、ありますよね。
でも、素直に実装しようとすると…

  • プレイヤー用のベースクラスに is_stunned フラグを足す
  • _physics_process() のあちこちに「スタン中なら return」みたいな条件分岐を追加
  • 敵クラスにも同じような処理をコピペ

…と、どんどん「継承ツリー+条件分岐地獄」になりがちです。
さらに「スタン中は頭の上にピヨピヨマークを出したい」とか「スタン解除時にSEを鳴らしたい」などの仕様が増えると、ベースクラスが太りすぎて管理がつらくなります。

そこで今回は、「スタン状態」を 1 つのコンポーネントとして切り出してしまいましょう。
どんなキャラにもポン付けできる 「StunEffect」コンポーネントを作って、親ノードの _physics_process() を一時停止しつつ、頭上にピヨりマークを表示できるようにします。

【Godot 4】一瞬でスタン演出を合成!「StunEffect」コンポーネント

このコンポーネントのゴールはシンプルです。

  • 親ノード(プレイヤーや敵など)の _physics_process() を「スタン中だけ実行させない」
  • スタン中は頭上にピヨりマーク(任意のシーン)を表示する
  • タイマーで自動解除もできるし、スクリプトから手動解除もできる
  • どのノードにも「アタッチするだけ」で導入できる(継承不要)

では、フルコードから見ていきましょう。

StunEffect.gd フルコード


extends Node
class_name StunEffect
## 親ノードの「スタン状態」を管理するコンポーネント。
## - スタン中は親の _physics_process() を停止
## - 頭上にピヨりマークを表示
## - タイマーで自動解除 or 手動解除の両対応

@export var auto_recover: bool = true:
	set(value):
		auto_recover = value
		if not auto_recover and is_instance_valid(_timer):
			_timer.stop()
## スタンを自動解除するかどうか。
## true なら duration 秒後に自動的に解除します。

@export_range(0.0, 60.0, 0.1, "suffix:s") var duration: float = 1.5
## スタン継続時間(秒)。auto_recover が true のときのみ使用されます。

@export var stun_marker_scene: PackedScene
## ピヨりマークのシーン。
## 例: PiyoMarker.tscn(AnimatedSprite2D や Sprite2D など)
## 未設定の場合はマーカー表示をスキップします。

@export var marker_offset: Vector2 = Vector2(0, -32)
## ピヨりマークの表示位置オフセット。
## 親ノードのローカル座標からの相対位置です。

@export var disable_parent_physics_process: bool = true
## true の場合、スタン中は親ノードの _physics_process() を止めます。
## 物理挙動を完全に止めたいときは true 推奨。

@export var disable_parent_input: bool = true
## true の場合、親が _unhandled_input() を持っていれば、
## スタン中は入力処理をスキップするためのフラグを立てます。
## 実際のスキップ処理は、親スクリプト側で is_stunned フラグを見る形で実装します。

var _is_stunned: bool = false setget _set_is_stunned
## 内部状態としてのスタンフラグ。
## 親からは is_stunned プロパティ経由で参照可能です。

var is_stunned: bool:
	get:
		return _is_stunned

var _parent_original_physics_process: bool = true
## 親ノードが元々 _physics_process() を有効にしていたかどうかを保存します。

var _marker_instance: Node2D
var _timer: Timer


func _ready() -> void:
	# 親ノードを取得しておく(存在しない場合は警告を出す)
	if get_parent() == null:
		push_warning("StunEffect: 親ノードが存在しません。このコンポーネントは何かの子ノードとして使ってください。")
	
	# タイマーを生成して子として追加
	_timer = Timer.new()
	_timer.one_shot = true
	_timer.autostart = false
	add_child(_timer)
	_timer.timeout.connect(_on_timer_timeout)
	
	# 親の元々の physics_process 有効状態を記録
	if get_parent() != null:
		_parent_original_physics_process = get_parent().is_physics_processing()
	
	# すでに is_stunned が true なら(シーン上で初期値を変えた場合など)即スタン開始
	if _is_stunned:
		_apply_stun_state()


func _physics_process(delta: float) -> void:
	# ピヨりマークを親の位置に追従させる
	if _is_stunned and is_instance_valid(_marker_instance) and is_instance_valid(get_parent()):
		var parent_2d := get_parent() as Node2D
		if parent_2d:
			_marker_instance.global_position = parent_2d.global_position + marker_offset


func stun(duration_override: float = -1.0) -> void:
	## 外部からスタンを開始するためのメソッド。
	## duration_override >= 0 の場合、その値でスタン時間を上書きします。
	if _is_stunned:
		# すでにスタン中なら、タイマーだけ延長(auto_recover が有効な場合)
		if auto_recover and is_instance_valid(_timer):
			var d := duration_override if duration_override >= 0.0 else duration
			_timer.start(d)
		return
	
	_is_stunned = true
	_apply_stun_state()
	
	if auto_recover:
		var d := duration_override if duration_override >= 0.0 else duration
		_timer.start(d)


func recover() -> void:
	## 外部からスタン解除するためのメソッド。
	if not _is_stunned:
		return
	
	_is_stunned = false
	_clear_stun_state()
	
	# タイマーは止めておく
	if is_instance_valid(_timer):
		_timer.stop()


func toggle() -> void:
	## デバッグ用:スタン状態をトグルします。
	if _is_stunned:
		recover()
	else:
		stun()


func _set_is_stunned(value: bool) -> void:
	# エディタから is_stunned をいじられた場合に対応するためのセッター
	if _is_stunned == value:
		return
	
	_is_stunned = value
	if not is_inside_tree():
		return
	
	if _is_stunned:
		_apply_stun_state()
	else:
		_clear_stun_state()


func _apply_stun_state() -> void:
	## スタン開始時の処理
	var parent := get_parent()
	if parent == null:
		return
	
	# 親の _physics_process を止める
	if disable_parent_physics_process:
		_parent_original_physics_process = parent.is_physics_processing()
		parent.set_physics_process(false)
	
	# 親に is_stunned プロパティがあれば、それも true にしてあげる
	if "is_stunned" in parent:
		parent.is_stunned = true
	
	# 親が「入力を止めるフラグ」を持っている想定の場合
	if disable_parent_input and "can_receive_input" in parent:
		parent.can_receive_input = false
	
	# ピヨりマークを生成
	_spawn_marker()


func _clear_stun_state() -> void:
	## スタン解除時の処理
	var parent := get_parent()
	if parent == null:
		return
	
	# 親の _physics_process を元の状態に戻す
	if disable_parent_physics_process:
		parent.set_physics_process(_parent_original_physics_process)
	
	# 親に is_stunned プロパティがあれば false に
	if "is_stunned" in parent:
		parent.is_stunned = false
	
	# 入力フラグも戻す
	if disable_parent_input and "can_receive_input" in parent:
		parent.can_receive_input = true
	
	# ピヨりマークを削除
	_despawn_marker()


func _spawn_marker() -> void:
	## ピヨりマークの生成
	_despawn_marker() # 二重生成防止
	
	if stun_marker_scene == null:
		# シーン未指定なら何もしない
		return
	
	var instance := stun_marker_scene.instantiate()
	
	# 2D 用のマーカーを想定
	if not instance is Node2D:
		push_warning("StunEffect: stun_marker_scene は Node2D 系のシーンを推奨します。")
	
	_marker_instance = instance
	get_tree().current_scene.add_child(_marker_instance)
	
	# 初期位置を設定
	var parent_2d := get_parent() as Node2D
	if parent_2d:
		_marker_instance.global_position = parent_2d.global_position + marker_offset


func _despawn_marker() -> void:
	## ピヨりマークの削除
	if is_instance_valid(_marker_instance):
		_marker_instance.queue_free()
	_marker_instance = null


func _on_timer_timeout() -> void:
	## タイマー満了時(自動解除)
	if _is_stunned:
		recover()

使い方の手順

ここからは、実際にプレイヤーや敵に組み込む手順を見ていきましょう。

例1: プレイヤーにスタンを付与する

想定するシーン構成はこんな感じです。

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

手順①: ピヨりマーク用シーンを用意する

  1. 新規シーンを作成し、ルートを Node2D または Sprite2D にします。
  2. 名前を PiyoMarker などにし、画像やアニメーションを設定します。
  3. このシーンを res://effects/PiyoMarker.tscn などのパスで保存します。

シンプルな例:

PiyoMarker (Node2D)
 └── Sprite2D (ピヨピヨ画像)

手順②: プレイヤーシーンに StunEffect を追加

  1. プレイヤーシーンを開きます(Player.tscn)。
  2. ルートの CharacterBody2D を右クリック → 「子ノードを追加」。
  3. Node を追加し、名前を StunEffect に変更。
  4. StunEffect ノードに、先ほどの StunEffect.gd スクリプトをアタッチします。
  5. インスペクタで以下を設定します:
    • StunEffect.stun_marker_scenePiyoMarker.tscn を指定
    • duration(スタン時間)を好みの秒数に
    • marker_offset で頭上の位置を微調整

手順③: プレイヤースクリプトからスタンを呼び出す

プレイヤー側のスクリプト(例: Player.gd)は、できるだけシンプルに保ちたいので、
スタンロジックは StunEffect に丸投げします。


extends CharacterBody2D

@export var move_speed: float = 200.0

var is_stunned: bool = false
var can_receive_input: bool = true

@onready var stun_effect: StunEffect = $StunEffect


func _physics_process(delta: float) -> void:
	# StunEffect が disable_parent_physics_process = true の場合、
	# スタン中はそもそもこの関数が呼ばれないので、ここでの分岐は必須ではありません。
	# ただし将来の拡張のために、あえてフラグを残しています。
	if is_stunned:
		velocity = Vector2.ZERO
		move_and_slide()
		return
	
	# 通常の移動処理
	var input_vector = Vector2.ZERO
	if can_receive_input:
		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 * move_speed
	move_and_slide()


func take_damage(amount: int, cause_stun: bool = false) -> void:
	# 何らかのダメージ処理
	print("Player took ", amount, " damage.")
	
	if cause_stun:
		# スタンを付与(1.5秒だけスタンさせる例)
		stun_effect.stun(1.5)

これで、「ダメージを受けたときだけスタンさせる」「特定の攻撃だけスタン付与」などが簡単に制御できます。

手順④: 敵キャラにもコピペで導入

敵キャラにも同じコンポーネントを使いまわしてみましょう。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── StunEffect (Node)

敵スクリプト側は、プレイヤーと同じように StunEffect を参照するだけです。


extends CharacterBody2D

var is_stunned: bool = false
var can_receive_input: bool = true

@onready var stun_effect: StunEffect = $StunEffect


func _physics_process(delta: float) -> void:
	if is_stunned:
		velocity = Vector2.ZERO
		move_and_slide()
		return
	
	# ここにパトロール AI や追尾処理などを書く
	# 例: プレイヤーに向かって移動するなど


func stun_from_player_attack() -> void:
	# プレイヤー攻撃から呼ばれる想定
	stun_effect.stun(2.0) # 敵は 2 秒スタンさせる例

プレイヤーと敵で全く同じコンポーネントを使い回せているのがポイントですね。


メリットと応用

この StunEffect コンポーネントを導入すると、いくつか嬉しいポイントがあります。

  • 継承ツリーをいじらなくていい
    既存の Player.gdEnemy.gd をほぼそのままに、スタン機能だけ後付けできます。
  • 「スタン」の仕様変更が一箇所で完結
    スタン時間、ピヨりマークの見た目、親の _physics_process() の止め方などを、
    コンポーネント内のコードだけ触れば全キャラに反映されます。
  • シーン構造がスッキリする
    「スタン用の子ノード」「タイマー」「エフェクト」などを個別に生やす必要がなく、
    StunEffect ひとつにまとまっているので、シーンツリーが見やすくなります。
  • レベルデザイン時に「スタンの有無」をノード単位で切り替えられる
    「この敵だけスタン無効にしたい」という場合は、単に StunEffect ノードを削除するだけでOKです。

応用としては、例えば以下のような拡張が考えられます。

  • スタン開始・解除時にサウンドエフェクトを鳴らす
  • スタン中は色を変える・シェーダーで点滅させる
  • スタン中は特定の入力だけ許可(ジャンプだけOKなど)

最後に、スタン開始時に親ノードの色を少し暗くする簡単な改造案を載せておきます。


func _apply_stun_state() -> void:
	var parent := get_parent()
	if parent == null:
		return
	
	if disable_parent_physics_process:
		_parent_original_physics_process = parent.is_physics_processing()
		parent.set_physics_process(false)
	
	if "is_stunned" in parent:
		parent.is_stunned = true
	
	if disable_parent_input and "can_receive_input" in parent:
		parent.can_receive_input = false
	
	# --- ここから追加: 親の Modulate を暗くする ---
	var parent_2d := parent as CanvasItem
	if parent_2d:
		parent_2d.modulate = Color(0.7, 0.7, 0.7)
	# --- ここまで追加 ---
	
	_spawn_marker()


func _clear_stun_state() -> void:
	var parent := get_parent()
	if parent == null:
		return
	
	if disable_parent_physics_process:
		parent.set_physics_process(_parent_original_physics_process)
	
	if "is_stunned" in parent:
		parent.is_stunned = false
	
	if disable_parent_input and "can_receive_input" in parent:
		parent.can_receive_input = true
	
	# --- ここから追加: 親の Modulate を元に戻す ---
	var parent_2d := parent as CanvasItem
	if parent_2d:
		parent_2d.modulate = Color.WHITE
	# --- ここまで追加 ---
	
	_despawn_marker()

このように、「スタン時にやりたいこと」をどんどん StunEffect に合成していけば、
プレイヤーや敵のスクリプトはどんどんシンプルになっていきます。
継承より合成、どんどん進めていきましょう。