Godotでアクションゲームを作っていると、スタミナ管理ってだいたいこうなりがちですよね:

  • プレイヤーシーンにスタミナ用の変数を直書き
  • ダッシュや回避のスクリプトの中で直接スタミナを増減
  • UI側(スタミナバー)もプレイヤーのスクリプトを直接参照

最初はそれでも動きますが、

  • 敵にもスタミナを入れたい
  • 別キャラ(別シーン)にも同じ仕組みを使いたい
  • スタミナの仕様をあとから変えたい(回復速度・最大値・ロックなど)

といったタイミングで、「あれ、あちこちに同じようなスタミナ処理書いてない?」となりがちです。
Godotは継承もしやすいので、PlayerWithStamina.gd みたいな派生クラスを作りたくなりますが、継承を重ねるとだんだん管理がつらくなっていきます。

そこで今回は、「スタミナという機能」を 1 個のコンポーネントに閉じ込めて、どのキャラにもポン付けできるようにした「StaminaBar コンポーネント」を紹介します。
親ノード(プレイヤーや敵)は「スタミナを持っている」という事実だけを利用し、スタミナの増減ロジックは全部コンポーネントにお任せするスタイルですね。

【Godot 4】スタミナ管理をまるごとコンポーネント化!「StaminaBar」コンポーネント

この StaminaBar は、

  • アクション発動時にスタミナを消費
  • 時間経過でスタミナを自動回復
  • ゼロのときは行動を制限
  • UI(ProgressBar など)への反映

といった、典型的なスタミナ管理を 1 つのノードにまとめたコンポーネントです。
プレイヤー・敵・動くギミックなど、どんなノードにもアタッチして使えるようにしてあります。


フルコード:StaminaBar.gd


extends Node
class_name StaminaBar
"""
スタミナ管理コンポーネント

・アクション時にスタミナを消費
・時間経過で自動回復
・UI(ProgressBar, TextureProgressBar, Label など)に値をバインド可能
・親ノードは「スタミナを持つ」という事実だけを利用すればOK

想定利用例:
  - Player (CharacterBody2D / 3D)
  - Enemy
  - ダッシュ可能な動く床 など
"""

## ====== 設定パラメータ(インスペクタから調整) ======

@export_category("Stamina Settings")

@export_range(0.0, 9999.0, 1.0)
var max_stamina: float = 100.0:
	set(value):
		max_stamina = maxf(0.0, value)
		# 最大値を変えたときは現在値もクランプしておく
		current_stamina = clampf(current_stamina, 0.0, max_stamina)
		_update_ui()

@export_range(0.0, 9999.0, 1.0)
var initial_stamina: float = 100.0:
	## シーン開始時の初期スタミナ量
	set(value):
		initial_stamina = maxf(0.0, value)

@export_range(0.0, 9999.0, 1.0)
var regen_per_second: float = 15.0
## 1秒あたりの自動回復量。0 にすると自動回復なし。

@export_range(0.0, 10.0, 0.1)
var regen_delay: float = 1.0
## スタミナを消費してから、回復が始まるまでの待ち時間(秒)

@export var can_go_negative: bool = false
## true にするとスタミナがマイナスまで減る(いわゆる「オーバー消費」)ことを許可。
## false の場合は 0 で止まり、これ以上の消費はできない。

@export_category("UI Binding")

@export var auto_bind_progress_bar: bool = false
## true の場合、子孫ノードから ProgressBar / TextureProgressBar を自動探索してバインド。
## 自前で bind_to_progress_bar() を呼ぶなら false でもOK。

@export var auto_bind_label: bool = false
## true の場合、子孫ノードから Label を自動探索してバインドし、「現在値 / 最大値」を表示。

@export var progress_bar_path: NodePath
## 特定の ProgressBar / TextureProgressBar を指定したい場合に使う。
## 未指定かつ auto_bind_progress_bar = true の場合は自動探索にフォールバック。

@export var label_path: NodePath
## 特定の Label を指定したい場合に使う。
## 未指定かつ auto_bind_label = true の場合は自動探索。

## ====== ランタイム状態 ======

var current_stamina: float = 0.0:
	set(value):
		current_stamina = value
		# クランプ処理
		if not can_go_negative:
			current_stamina = clampf(current_stamina, 0.0, max_stamina)
		else:
			current_stamina = minf(current_stamina, max_stamina)
		_update_ui()
		# スタミナ変化シグナルを発火
		stamina_changed.emit(current_stamina, max_stamina)

var _time_since_last_spend: float = 0.0

## ====== シグナル ======

signal stamina_changed(current: float, max: float)
## スタミナが変化したときに発火

signal stamina_depleted()
## スタミナが 0 になった瞬間に発火(can_go_negative = false のときのみ意味がある)

signal stamina_recovered_to_full()
## スタミナが最大値まで回復した瞬間に発火

## ====== 内部参照(UIなど) ======

var _progress_bar: ProgressBar
var _label: Label


func _ready() -> void:
	# 初期スタミナを設定
	current_stamina = clampf(initial_stamina, 0.0, max_stamina)
	_time_since_last_spend = 9999.0  # すぐに回復開始できるように大きめの値

	# UIバインド
	if auto_bind_progress_bar:
		_auto_bind_progress_bar()
	elif progress_bar_path != NodePath(""):
		var node := get_node_or_null(progress_bar_path)
		if node and node is ProgressBar:
			_progress_bar = node

	if auto_bind_label:
		_auto_bind_label()
	elif label_path != NodePath(""):
		var lnode := get_node_or_null(label_path)
		if lnode and lnode is Label:
			_label = lnode

	_update_ui()


func _process(delta: float) -> void:
	# 時間経過
	_time_since_last_spend += delta

	# 回復処理
	if regen_per_second > 0.0 and _time_since_last_spend >= regen_delay:
		if current_stamina < max_stamina:
			var before := current_stamina
			current_stamina += regen_per_second * delta
			# 最大値に達した瞬間を検知
			if before < max_stamina and current_stamina >= max_stamina:
				stamina_recovered_to_full.emit()


## ====== パブリックAPI ======

func has_enough(amount: float) -> bool:
	"""
	指定量のスタミナを消費できるかどうかを返す。
	can_go_negative = true の場合は常に true を返す。
	"""
	if amount <= 0.0:
		return true
	if can_go_negative:
		return true
	return current_stamina >= amount


func try_spend(amount: float) -> bool:
	"""
	スタミナを消費するメイン関数。
	- 消費に成功したら true
	- 足りなくて消費できなかったら false
	"""
	if amount <= 0.0:
		return true

	if not can_go_negative and current_stamina < amount:
		# 足りないので消費失敗
		return false

	var before := current_stamina
	current_stamina -= amount
	_time_since_last_spend = 0.0  # 回復までの待ち時間をリセット

	# 0 になった瞬間
	if not can_go_negative and before > 0.0 and current_stamina <= 0.0:
		stamina_depleted.emit()

	return true


func add_stamina(amount: float) -> void:
	"""
	外部からスタミナを回復させたいときに呼ぶ。
	(ポーション・休憩ポイント・スキルなど)
	"""
	if amount <= 0.0:
		return
	var before := current_stamina
	current_stamina += amount
	# 手動回復も「消費からの時間」として扱うかどうかは好みだが、
	# ここでは時間リセットしない(自動回復のタイミングを崩さないため)。
	if before < max_stamina and current_stamina >= max_stamina:
		stamina_recovered_to_full.emit()


func set_stamina(value: float) -> void:
	"""
	現在スタミナを直接セットする。
	デバッグやチート、セーブデータ読み込みなどに便利。
	"""
	current_stamina = value


func get_ratio() -> float:
	"""
	0.0〜1.0 の範囲で現在スタミナの割合を返す。
	UI以外でも、「スタミナが少ないほど色を変える」などの表現に利用可能。
	"""
	if max_stamina <= 0.0:
		return 0.0
	return current_stamina / max_stamina


func bind_to_progress_bar(bar: ProgressBar) -> void:
	"""
	特定の ProgressBar / TextureProgressBar を手動でバインドする。
	"""
	_progress_bar = bar
	_update_ui()


func bind_to_label(label: Label) -> void:
	"""
	特定の Label を手動でバインドする。
	"""
	_label = label
	_update_ui()


## ====== 内部ヘルパー ======

func _update_ui() -> void:
	# ProgressBar 反映
	if _progress_bar:
		_progress_bar.min_value = 0.0
		_progress_bar.max_value = max_stamina
		_progress_bar.value = current_stamina

	# Label 反映("current / max" の形式)
	if _label:
		_label.text = "%d / %d" % [roundi(current_stamina), roundi(max_stamina)]


func _auto_bind_progress_bar() -> void:
	# progress_bar_path が指定されていればそちらを優先
	if progress_bar_path != NodePath(""):
		var node := get_node_or_null(progress_bar_path)
		if node and node is ProgressBar:
			_progress_bar = node
			return

	# 子孫から最初に見つかった ProgressBar / TextureProgressBar を利用
	var bars := get_tree().get_nodes_in_group("__temp_stamina_bar_search__")
	# 上の行はダミー。実際は get_tree() を使わず、子孫探索を行う。
	# Godot 4 では以下のように書くのがシンプル。
	for child in get_children():
		_progress_bar = _find_progress_bar_recursive(child)
		if _progress_bar:
			return


func _find_progress_bar_recursive(node: Node) -> ProgressBar:
	if node is ProgressBar:
		return node
	for child in node.get_children():
		var found := _find_progress_bar_recursive(child)
		if found:
			return found
	return null


func _auto_bind_label() -> void:
	# label_path が指定されていればそちらを優先
	if label_path != NodePath(""):
		var node := get_node_or_null(label_path)
		if node and node is Label:
			_label = node
			return

	for child in get_children():
		_label = _find_label_recursive(child)
		if _label:
			return


func _find_label_recursive(node: Node) -> Label:
	if node is Label:
		return node
	for child in node.get_children():
		var found := _find_label_recursive(child)
		if found:
			return found
	return null

使い方の手順

① コンポーネントをプロジェクトに追加する

  1. res://components/StaminaBar.gd など、好きな場所に上記コードを保存します。
  2. Godotエディタを再読み込みすると、スクリプトクラスとして StaminaBar が使えるようになります。

② プレイヤーにアタッチしてみる

例として 2D プレイヤーにスタミナを持たせる構成です。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── StaminaBar (Node)
 └── CanvasLayer
      └── StaminaUI (Control)
           └── StaminaProgress (TextureProgressBar)
  • Player ノードの子として Node を追加し、スクリプトに StaminaBar.gd をアタッチします。
  • UI 側に TextureProgressBar を作り、スタミナ表示用にします。

StaminaBar のインスペクタ設定例:

  • max_stamina = 100
  • initial_stamina = 100
  • regen_per_second = 20
  • regen_delay = 1.0
  • auto_bind_progress_bar = true
  • progress_bar_path = "CanvasLayer/StaminaUI/StaminaProgress"(or 自動探索に任せる)

③ プレイヤーの入力処理からスタミナを消費する

プレイヤーのスクリプト例です(ダッシュでスタミナを消費するパターン)。


extends CharacterBody2D

@onready var stamina: StaminaBar = $StaminaBar

const DASH_COST := 25.0
const DASH_SPEED := 600.0
const WALK_SPEED := 200.0

var _is_dashing := false
var _dash_time := 0.15
var _dash_timer := 0.0


func _physics_process(delta: float) -> void:
	var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	var velocity_dir := input_dir.normalized()

	# ダッシュ開始判定
	if Input.is_action_just_pressed("dash"):
		# スタミナが足りていればダッシュ開始
		if stamina.try_spend(DASH_COST):
			_is_dashing = true
			_dash_timer = _dash_time

	# ダッシュ継続
	if _is_dashing:
		_dash_timer -= delta
		if _dash_timer <= 0.0:
			_is_dashing = false

	# 速度決定
	var speed := WALK_SPEED
	if _is_dashing:
		speed = DASH_SPEED

	velocity = velocity_dir * speed
	move_and_slide()

ポイント:

  • stamina.try_spend(DASH_COST)true のときだけダッシュ開始。
  • スタミナが足りない場合はダッシュしない(キャンセル)。
  • スタミナの回復・UIへの反映は全部 StaminaBar にお任せ。

プレイヤー側は「スタミナを使えるかどうか」を聞くだけなので、プレイヤーのスクリプトからスタミナロジックがほぼ消えます

④ 敵やギミックにもそのまま流用する

敵キャラにも同じコンポーネントを付けるだけで、スタミナ付きAIが作れます。

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

敵のスクリプト例(スタミナがあるときだけ突進攻撃):


extends CharacterBody2D

@onready var stamina: StaminaBar = $StaminaBar

const CHARGE_COST := 40.0
const CHARGE_SPEED := 500.0
const WALK_SPEED := 100.0

var _is_charging := false
var _charge_time := 0.5
var _charge_timer := 0.0


func _physics_process(delta: float) -> void:
	var dir := Vector2.LEFT  # 単純な例として左に歩き続ける

	# 一定条件で突進開始(例: プレイヤーが近いときなど)
	if not _is_charging and _should_start_charge():
		if stamina.try_spend(CHARGE_COST):
			_is_charging = true
			_charge_timer = _charge_time

	if _is_charging:
		_charge_timer -= delta
		if _charge_timer <= 0.0:
			_is_charging = false

	var speed := WALK_SPEED
	if _is_charging:
		speed = CHARGE_SPEED

	velocity = dir * speed
	move_and_slide()


func _should_start_charge() -> bool:
	# 実際はプレイヤーとの距離判定などを書く
	return randf() < 0.01

敵用に別クラスを継承してスタミナを足す必要はありません。
StaminaBar を付けたノードはスタミナを持つ」というルールだけ守ればOKです。


メリットと応用

  • シーン構造がシンプル
    スタミナのために PlayerWithStamina みたいな派生シーンを作らず、既存の Player に StaminaBar ノードを 1 個足すだけで済みます。
  • ロジックの再利用性が高い
    プレイヤー・敵・ギミック・召喚獣…どれも同じコンポーネントをポン付けできます。
  • 仕様変更に強い
    「スタミナは回復しないモードにしたい」「オーバー消費できるようにしたい」などの変更が、StaminaBar 内部の修正だけで全キャラに反映されます。
  • UIとの結びつきも疎結合
    ProgressBar / Label へのバインドはオプション扱いなので、UIを変えたいときもコンポーネント側を少し触るだけで済みます。

継承ベースで「スタミナ付きプレイヤー」「スタミナ付き敵」クラスを増やしていくと、あとから共通の仕様変更をかけるのが大変になりがちです。
今回のように「スタミナ」という概念を 1 つのコンポーネントに閉じ込めることで、継承のツリーではなく、ノード構成で機能を組み合わせる形にできるのが大きなメリットですね。

改造案:スタミナが少ないときに警告エフェクトを出す

例えば、スタミナが 25% を下回ったらプレイヤーを赤く点滅させたい、という場合は、StaminaBar に以下のような補助関数を足してもよいでしょう。


func is_low(threshold_ratio: float = 0.25) -> bool:
	"""
	スタミナが指定割合(threshold_ratio)を下回っているかどうか。
	デフォルトは 25% 未満で true を返す。
	"""
	return get_ratio() < threshold_ratio

プレイヤー側ではこんな感じで利用できます:


func _process(delta: float) -> void:
	if stamina.is_low():
		# ここで点滅させたり、UIを赤くしたり
		modulate = Color(1, 0.6, 0.6)
	else:
		modulate = Color(1, 1, 1)

このように、小さなユーティリティ関数を少しずつ足していくことで、
「スタミナまわりの知識」が全部 StaminaBar に集約されていくのがコンポーネント指向の気持ちよさですね。
ぜひ自分のプロジェクトのスタミナ管理も、継承ではなくコンポーネントとして切り出してみてください。