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_timeとcooldown_remainingを持たせる)- HUD内に
DashSlot(Control)をコピペし、アイコン画像を変更 DashSlotの中にSkillCooldownUIをコピペし、skill_node_pathだけDashSkillに変更
これだけで、UI側のロジックは一切増やさずに、スキルスロットを量産できます。
「継承ベースの FireballButton / DashButton クラスを増やす」より、圧倒的にスッキリしますね。
メリットと応用
SkillCooldownUI コンポーネントを使うメリットを、コンポーネント指向の観点から整理してみます。
- UIロジックの再利用性が高い
「スキルのクールタイムを表示する」という責務だけを切り出したコンポーネントなので、
スキルの種類や発動条件に関係なく、どのスキルにも同じUIコンポーネントを貼るだけでOKです。 - プレイヤー/スキル側のコードが肥大化しない
プレイヤーのスクリプトは「スキルを使う」ことだけに集中でき、
「UIにどう表示するか」は完全に別コンポーネントに任せられます。 - シーン構造が浅く、見通しがよくなる
「スキルボタン専用の巨大なシーンツリー」を作らなくても、
任意のControlにSkillCooldownUIをアタッチするだけで済みます。 - テストやデバッグがしやすい
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 をベースにいろいろ拡張してみてください。
