GodotでアクションゲームやRPGを作り始めると、だいたい早い段階で「スキルのクールタイムをUIに出したいな…」という欲求が出てきますよね。
でも、よくある実装パターンはこんな感じになりがちです。

  • プレイヤーシーンのスクリプトが、スキル発動とクールタイム管理とUI更新を全部まとめてやる
  • UI側のスクリプトが、プレイヤーの内部状態(クールタイム変数など)を直接参照する
  • スキルごとに似たようなUIコードをコピペする

これを継承でどうにかしようとすると、

  • BaseSkillButton を作って、そこから FireballButton とか DashButton を継承する
  • プレイヤーのスクリプトに「このスキルが発動したらUIにも教える」コードを大量に書く

…みたいな「継承ツリー+深いノード階層」にハマりがちです。
スキルを増やすたびにUI側も修正が必要になって、だんだんカオスになっていくんですよね。

そこで今回は、「スキルのクールタイム表示」だけに責務を絞ったコンポーネント
「SkillCooldownUI」 を用意します。
UIノードにペタッとアタッチするだけで、指定したスキルの再使用時間を監視し、

  • アイコンを暗くする(グレーアウト)
  • 残り秒数を数字で表示する

といった処理を、きれいに「合成(Composition)」で実現していきましょう。

【Godot 4】スキルCDをスマートに可視化!「SkillCooldownUI」コンポーネント

コンポーネントの前提

この記事では、スキル側は「クールタイムに関する情報やシグナルを提供するオブジェクト」として扱います。
具体的には、スキル側(例:Skill ノードや SkillResource)が以下のようなインターフェースを持っていることを想定します。

  • プロパティ
    • cooldown_time: float … クールタイムの総時間(秒)
    • cooldown_remaining: float … 残りクールタイム(秒)
  • シグナル
    • cooldown_started(total_time: float)
    • cooldown_updated(remaining: float)
    • cooldown_finished()

もちろん、既存プロジェクトに合わせて名前を変えてもOKです。
このコンポーネント側で、ある程度「ポーリング方式」にも対応するので、シグナルがなくても動きます。


フルコード(SkillCooldownUI.gd)


extends Control
class_name SkillCooldownUI
## スキルのクールタイムを監視して、
## アイコンの暗転+残り時間テキストを表示するコンポーネント。
##
## 想定ノード構成:
##   SkillCooldownUI (Control)
##    ├── Icon (TextureRect)      # スキルアイコン
##    └── Label (Label)           # 残り秒数を表示(任意)

@export var skill_node_path: NodePath:
	## 監視対象となる「スキル」ノードへのパス。
	## 例: プレイヤーシーン内の FireballSkill ノードなど。
	get:
		return skill_node_path
	set(value):
		skill_node_path = value
		# エディタ上で設定された時にも反映できるように
		if is_inside_tree():
			_resolve_skill_node()

@export var icon_node_path: NodePath = NodePath("Icon"):
	## アイコンを表示する TextureRect へのパス。
	## デフォルトで子ノード "Icon" を参照します。
	get:
		return icon_node_path
	set(value):
		icon_node_path = value
		if is_inside_tree():
			_resolve_icon_node()

@export var label_node_path: NodePath = NodePath("Label"):
	## 残り時間を表示する Label へのパス。
	## Label がない場合は空文字のままでもOK。
	get:
		return label_node_path
	set(value):
		label_node_path = value
		if is_inside_tree():
			_resolve_label_node()

@export var show_decimal: bool = false:
	## true の場合、小数1桁まで表示 (例: 3.4)
	## false の場合、整数のみ (例: 3)
	get:
		return show_decimal
	set(value):
		show_decimal = value
		_update_label_text(_cooldown_remaining)

@export var min_show_threshold: float = 0.1:
	## この秒数未満になったら、残り時間テキストを非表示にする。
	## 0 にすると常に表示。
	get:
		return min_show_threshold
	set(value):
		min_show_threshold = max(value, 0.0)

@export var dim_color: Color = Color(0, 0, 0, 0.6):
	## クールタイム中にアイコンに乗せる「暗転カラー」。
	## 黒+アルファでグレーアウト感を出す。
	get:
		return dim_color
	set(value):
		dim_color = value
		_update_icon_modulate()

@export var active_color: Color = Color(1, 1, 1, 1):
	## クールタイムが終わったときのアイコンカラー。
	## 通常は白(1,1,1,1)のままでOK。
	get:
		return active_color
	set(value):
		active_color = value
		_update_icon_modulate()

@export var use_polling: bool = true:
	## true の場合、毎フレーム skill.cooldown_remaining を読むポーリング方式を併用。
	## スキル側にシグナルがない場合でも動かせるための保険です。
	get:
		return use_polling
	set(value):
		use_polling = value

@export var auto_hide_when_ready: bool = false:
	## クールタイムが 0 のときに、このUI自体を非表示にするかどうか。
	get:
		return auto_hide_when_ready
	set(value):
		auto_hide_when_ready = value
		_update_visibility()

# 内部状態
var _skill: Node = null
var _icon: TextureRect = null
var _label: Label = null

var _cooldown_total: float = 0.0
var _cooldown_remaining: float = 0.0
var _is_on_cooldown: bool = false


func _ready() -> void:
	_resolve_skill_node()
	_resolve_icon_node()
	_resolve_label_node()
	_connect_skill_signals()
	# 初期状態の反映
	_sync_from_skill()
	_update_icon_modulate()
	_update_label_text(_cooldown_remaining)
	_update_visibility()


func _process(delta: float) -> void:
	if use_polling and _skill and _has_skill_property("cooldown_remaining"):
		var new_remaining := float(_skill.cooldown_remaining)
		# 値が変わっていたら更新
		if !is_equal_approx(new_remaining, _cooldown_remaining):
			_cooldown_remaining = max(new_remaining, 0.0)
			_is_on_cooldown = _cooldown_remaining > 0.0
			_update_icon_modulate()
			_update_label_text(_cooldown_remaining)
			_update_visibility()


# --- ノード解決系 ---------------------------------------------------------

func _resolve_skill_node() -> void:
	if skill_node_path == NodePath():
		_skill = null
		return
	if not is_inside_tree():
		return
	var node := get_node_or_null(skill_node_path)
	if node == null:
		push_warning("SkillCooldownUI: skill_node_path '%s' が見つかりません。" % [skill_node_path])
		_skill = null
		return
	_skill = node
	_connect_skill_signals()
	_sync_from_skill()


func _resolve_icon_node() -> void:
	if icon_node_path == NodePath():
		_icon = null
		return
	if not is_inside_tree():
		return
	_icon = get_node_or_null(icon_node_path) as TextureRect
	if _icon == null:
		push_warning("SkillCooldownUI: icon_node_path '%s' が TextureRect として見つかりません。" % [icon_node_path])


func _resolve_label_node() -> void:
	if label_node_path == NodePath():
		_label = null
		return
	if not is_inside_tree():
		return
	_label = get_node_or_null(label_node_path) as Label
	if _label == null:
		push_warning("SkillCooldownUI: label_node_path '%s' が Label として見つかりません。" % [label_node_path])


# --- スキルとの連携 -------------------------------------------------------

func _connect_skill_signals() -> void:
	if _skill == null:
		return

	# 既存接続を一旦解除(シーン再ロードなどに備える)
	if _skill.is_connected("cooldown_started", Callable(self, "_on_skill_cooldown_started")):
		_skill.disconnect("cooldown_started", Callable(self, "_on_skill_cooldown_started"))
	if _skill.is_connected("cooldown_updated", Callable(self, "_on_skill_cooldown_updated")):
		_skill.disconnect("cooldown_updated", Callable(self, "_on_skill_cooldown_updated"))
	if _skill.is_connected("cooldown_finished", Callable(self, "_on_skill_cooldown_finished")):
		_skill.disconnect("cooldown_finished", Callable(self, "_on_skill_cooldown_finished"))

	# スキル側が該当シグナルを持っていれば接続する
	if _skill.has_signal("cooldown_started"):
		_skill.connect("cooldown_started", Callable(self, "_on_skill_cooldown_started"))
	if _skill.has_signal("cooldown_updated"):
		_skill.connect("cooldown_updated", Callable(self, "_on_skill_cooldown_updated"))
	if _skill.has_signal("cooldown_finished"):
		_skill.connect("cooldown_finished", Callable(self, "_on_skill_cooldown_finished"))


func _sync_from_skill() -> void:
	# スキル側に total / remaining の情報があれば初期値として拾う
	if _skill == null:
		_cooldown_total = 0.0
		_cooldown_remaining = 0.0
		_is_on_cooldown = false
		return

	if _has_skill_property("cooldown_time"):
		_cooldown_total = float(_skill.cooldown_time)

	if _has_skill_property("cooldown_remaining"):
		_cooldown_remaining = max(float(_skill.cooldown_remaining), 0.0)
		_is_on_cooldown = _cooldown_remaining > 0.0
	else:
		_cooldown_remaining = 0.0
		_is_on_cooldown = false


func _has_skill_property(prop_name: String) -> bool:
	return _skill != null and _skill.has_variable(prop_name)


# --- スキルシグナルのハンドラ --------------------------------------------

func _on_skill_cooldown_started(total_time: float) -> void:
	_cooldown_total = max(total_time, 0.0)
	_cooldown_remaining = _cooldown_total
	_is_on_cooldown = _cooldown_remaining > 0.0
	_update_icon_modulate()
	_update_label_text(_cooldown_remaining)
	_update_visibility()


func _on_skill_cooldown_updated(remaining: float) -> void:
	_cooldown_remaining = max(remaining, 0.0)
	_is_on_cooldown = _cooldown_remaining > 0.0
	_update_icon_modulate()
	_update_label_text(_cooldown_remaining)
	_update_visibility()


func _on_skill_cooldown_finished() -> void:
	_cooldown_remaining = 0.0
	_is_on_cooldown = false
	_update_icon_modulate()
	_update_label_text(_cooldown_remaining)
	_update_visibility()


# --- UI更新系 -------------------------------------------------------------

func _update_icon_modulate() -> void:
	if _icon == null:
		return

	if _is_on_cooldown:
		# クールタイム中は暗転カラーを適用
		_icon.modulate = dim_color
	else:
		# 使用可能時は通常カラー
		_icon.modulate = active_color


func _update_label_text(remaining: float) -> void:
	if _label == null:
		return

	if remaining <= min_show_threshold:
		_label.text = ""
		return

	if show_decimal:
		_label.text = "%.1f" % remaining
	else:
		_label.text = str(int(ceil(remaining)))


func _update_visibility() -> void:
	if not auto_hide_when_ready:
		visible = true
		return

	# クールタイムが完全に0であれば非表示
	visible = _cooldown_remaining > 0.0

使い方の手順

① スキル側(例:FireballSkill)を用意する

まずは、プレイヤーなどにぶら下がっている「スキルノード」が、
クールタイム情報を持っている前提を作りましょう。
最低限、こんな感じのスクリプトがあればOKです。


extends Node
class_name FireballSkill

signal cooldown_started(total_time: float)
signal cooldown_updated(remaining: float)
signal cooldown_finished()

@export var cooldown_time: float = 5.0  # このスキルのクールタイム(秒)

var cooldown_remaining: float = 0.0


func use() -> void:
	if cooldown_remaining > 0.0:
		return # まだクールタイム中

	# ここに実際の「火の玉を撃つ処理」を書く
	print("Fireball!")

	# クールタイム開始
	cooldown_remaining = cooldown_time
	emit_signal("cooldown_started", cooldown_time)


func _process(delta: float) -> void:
	if cooldown_remaining > 0.0:
		cooldown_remaining = max(cooldown_remaining - delta, 0.0)
		emit_signal("cooldown_updated", cooldown_remaining)
		if cooldown_remaining == 0.0:
			emit_signal("cooldown_finished")

もちろん、既にスキルシステムがある場合は、それに合わせてプロパティ名/シグナル名を調整してください。

② UIシーンに SkillCooldownUI を配置する

次に、UI用のシーン(例:HUD.tscn)を作り、スキルのアイコン部分に SkillCooldownUI をアタッチします。

HUD (CanvasLayer)
 └── FireballSlot (Control)
      ├── Icon (TextureRect)
      ├── Label (Label)
      └── SkillCooldownUI (Control)

SkillCooldownUI を追加したら、インスペクターで以下を設定します。

  • skill_node_path … プレイヤーシーン内の FireballSkill へのパス
    • 例: ../../Player/FireballSkill など
  • icon_node_path … 通常は "Icon" のままでOK
  • label_node_path … 通常は "Label" のままでOK
  • show_decimal … 0.5秒精度で見せたいなら ON
  • auto_hide_when_ready … クールタイムがないときに消したいなら ON

③ プレイヤーとHUDをつなぐ

典型的なシーン構成例はこんな感じです。

Main (Node)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── FireballSkill (Node)
 └── HUD (CanvasLayer)
      └── FireballSlot (Control)
           ├── Icon (TextureRect)
           ├── Label (Label)
           └── SkillCooldownUI (Control)

SkillCooldownUI.skill_node_path を、../../Player/FireballSkill のように設定しておけば、
HUDとプレイヤーが直接強く結合することなく、UIコンポーネント側が「スキルの状態だけ」を監視してくれます。

④ スキルを増やしたいとき

例えば、ダッシュスキルを追加したくなったら、

  • DashSkill ノード+スクリプトを作る(cooldown_timecooldown_remaining を持たせる)
  • HUD内に DashSlot(Control)をコピペし、アイコン画像を変更
  • DashSlot の中に SkillCooldownUI をコピペし、skill_node_path だけ DashSkill に変更

これだけで、UI側のロジックは一切増やさずに、スキルスロットを量産できます。
「継承ベースの FireballButton / DashButton クラスを増やす」より、圧倒的にスッキリしますね。


メリットと応用

SkillCooldownUI コンポーネントを使うメリットを、コンポーネント指向の観点から整理してみます。

  • UIロジックの再利用性が高い
    「スキルのクールタイムを表示する」という責務だけを切り出したコンポーネントなので、
    スキルの種類や発動条件に関係なく、どのスキルにも同じUIコンポーネントを貼るだけでOKです。
  • プレイヤー/スキル側のコードが肥大化しない
    プレイヤーのスクリプトは「スキルを使う」ことだけに集中でき、
    「UIにどう表示するか」は完全に別コンポーネントに任せられます。
  • シーン構造が浅く、見通しがよくなる
    「スキルボタン専用の巨大なシーンツリー」を作らなくても、
    任意の ControlSkillCooldownUI をアタッチするだけで済みます。
  • テストやデバッグがしやすい
    SkillCooldownUI 単体でシーンを立ち上げて、テスト用のダミースキルをつなげれば、
    ゲーム本体に依存せずUI挙動の確認ができます。

改造案:残り時間の割合で円形ゲージを塗る

数字表示だけでなく、「アイコンの上に円形のクールタイムゲージを描きたい」ケースも多いと思います。
そんなときは、SkillCooldownUI を継承せずに、もう1つコンポーネントを横に置くのもアリですが、
簡単な例として SkillCooldownUI 内に「割合を返す関数」を追加しておくと便利です。


func get_cooldown_ratio() -> float:
	## 0.0 = クールタイムなし(使用可能)、1.0 = クールタイム開始直後
	if _cooldown_total <= 0.0:
		return 0.0
	return clamp(_cooldown_remaining / _cooldown_total, 0.0, 1.0)

この関数を用意しておけば、別のノード(例えば CooldownCircle という TextureProgressBar)から

  • value = skill_cooldown_ui.get_cooldown_ratio() * 100.0

のように参照するだけで、円形ゲージを同期させることができます。
「UIの見た目ロジック(円形かバーか)」と「クールタイムの数値管理」をさらに分離できるので、
コンポーネント指向的にも気持ちいい構成になりますね。

ぜひ、自分のプロジェクトのスキルシステムに合わせて、
SkillCooldownUI をベースにいろいろ拡張してみてください。