ボタンのクールダウン表示、どうしていますか?
毎回「別のTextureRectを重ねて不透明度を変える」「アニメーションプレイヤーでタイムラインを組む」「Tweenでサイズをいじる」…みたいな実装をしていると、ボタンの種類が増えたときにメンテがつらくなりますよね。
さらに、「扇形のクールダウン表示」をやろうとすると、TextureProgressBarのFill Modeでは微妙に足りなかったり、Polygon2Dで自前ポリゴンを組んだりと、結構な手間になりがちです。
そこでこの記事では、どんなボタンにもポン付けできる「クールダウン可視化コンポーネント」として、CooldownOverlayを用意しました。
親のTextureRectの上に半透明の扇形を描画して、クールダウン残り時間を一目で分かるようにしてくれます。
「ボタンの見た目は好きに作る」「クールダウン表示はコンポーネントに丸投げ」――まさに 継承より合成 (Composition over Inheritance) なアプローチですね。
【Godot 4】クールダウンを一目で見せる!「CooldownOverlay」コンポーネント
以下が、CooldownOverlay のフルコードです。CanvasItem を継承したシンプルなノードなので、TextureRect の子としてアタッチするだけで使えます。
extends CanvasItem
class_name CooldownOverlay
"""
親の TextureRect の上に半透明の扇形を描画して、
スキルやボタンのクールダウン状態を可視化するコンポーネント。
・親ノードのサイズに自動追従
・コードから start_cooldown() を呼ぶだけでアニメーション
・複数ボタンに簡単に使い回し可能
"""
@export_category("Cooldown Settings")
## クールダウンの総時間(秒)
@export var cooldown_duration: float = 5.0:
set(value):
cooldown_duration = max(value, 0.01) # 0は危険なので最小値を保証
## true のとき、クールダウン中のみ表示 / 終了時は自動で非表示
@export var hide_when_ready: bool = true
## クールダウンが開始した瞬間に 1.0 から 0.0 に向かって減るかどうか
## true: 残り時間ベース(よくあるクールダウンUI)
## false: 経過時間ベース(0→1で伸びていく)
@export var use_remaining_ratio: bool = true
@export_category("Visual")
## 扇形の色(アルファ値もここで指定)
@export var overlay_color: Color = Color(0, 0, 0, 0.6)
## 扇形の開始角度(度数法)。0度は右方向、反時計回りが正。
## 270度にすると「上から時計回り」に減っていくように見える。
@export var start_angle_deg: float = 270.0
## 扇形が回転する向き。true なら時計回り、false なら反時計回り。
@export var clockwise: bool = true
## 扇形の細かさ(頂点数)。大きいほど滑らかだが描画コストが増える。
@export_range(8, 128, 1, "or_greater")
@export var segments: int = 64
@export_category("Debug")
## エディタ上で常にプレビューしたいとき用
@export var preview_in_editor: bool = false
var _cooldown_time_left: float = 0.0
var _cooldown_active: bool = false
func _ready() -> void:
# 親のサイズに追従できるように、このノード自身はフルサイズで描画
set_notify_transform(true)
if hide_when_ready:
visible = preview_in_editor
func _process(delta: float) -> void:
if Engine.is_editor_hint() and preview_in_editor and not _cooldown_active:
# エディタプレビュー用:常に半分くらいのクールダウン状態で描画
_cooldown_time_left = cooldown_duration * 0.5
queue_redraw()
return
if not _cooldown_active:
return
_cooldown_time_left -= delta
if _cooldown_time_left <= 0.0:
_cooldown_time_left = 0.0
_cooldown_active = false
if hide_when_ready:
visible = false
queue_redraw()
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED:
# 親のサイズが変わったときなどに再描画
queue_redraw()
func _draw() -> void:
# クールダウンしていない & エディタプレビューも無効なら何も描かない
if not _cooldown_active and not (Engine.is_editor_hint() and preview_in_editor):
return
if cooldown_duration <= 0.0:
return
# 0.0 ~ 1.0 の割合を計算
var ratio: float
if use_remaining_ratio:
ratio = clamp(_cooldown_time_left / cooldown_duration, 0.0, 1.0)
else:
ratio = 1.0 - clamp(_cooldown_time_left / cooldown_duration, 0.0, 1.0)
if ratio <= 0.0:
# 何も描かない状態
return
# 親の TextureRect のサイズを取得(なければ自分のサイズ)
var parent_tex_rect := get_parent() as TextureRect
var size: Vector2 = Vector2.ZERO
if parent_tex_rect:
size = parent_tex_rect.size
else:
# 親が TextureRect でない場合は、自身のグローバル変換から適当なサイズを推定
size = get_rect().size
if size.x <= 0.0 or size.y <= 0.0:
return
# 扇形の中心と半径を決定(親の矩形の中心 & 最小辺の半分)
var center: Vector2 = size * 0.5
var radius: float = min(size.x, size.y) * 0.5
# 描画用の頂点配列を作成
var points: PackedVector2Array = PackedVector2Array()
points.append(center) # 扇形の中心
# 角度計算
var start_rad: float = deg_to_rad(start_angle_deg)
var sweep_rad: float = TAU * ratio # TAU = 2 * PI
# 時計回りなら角度を負方向に進める
if clockwise:
sweep_rad = -sweep_rad
# segments 個に分割して頂点を生成
var step: float = sweep_rad / segments
for i in range(segments + 1):
var angle: float = start_rad + step * i
var dir: Vector2 = Vector2(cos(angle), sin(angle))
points.append(center + dir * radius)
# 半透明の扇形を描画
draw_colored_polygon(points, overlay_color)
# ==============================
# Public API
# ==============================
## クールダウンを開始する。
## duration を省略すると export された cooldown_duration を使う。
func start_cooldown(duration: float = -1.0) -> void:
if duration > 0.0:
cooldown_duration = duration
_cooldown_time_left = cooldown_duration
_cooldown_active = true
visible = true
queue_redraw()
## クールダウンを即座に完了させる。
func finish_cooldown() -> void:
_cooldown_time_left = 0.0
_cooldown_active = false
if hide_when_ready:
visible = false
queue_redraw()
## クールダウン中かどうかを返す。
func is_on_cooldown() -> bool:
return _cooldown_active
## クールダウンの進行度(0.0~1.0)を返す。
## use_remaining_ratio が true のとき:
## 1.0 = 開始直後, 0.0 = 終了
## false のとき:
## 0.0 = 開始直後, 1.0 = 終了
func get_ratio() -> float:
if cooldown_duration <= 0.0:
return 0.0
var base := clamp(_cooldown_time_left / cooldown_duration, 0.0, 1.0)
return base if use_remaining_ratio else 1.0 - base
使い方の手順
ここでは、スキルボタンにクールダウン扇形を載せる具体例で説明します。
ノード構成はこんな感じにします:
SkillButton (TextureRect) ├── TextureRect(アイコン画像用・任意) └── CooldownOverlay (CanvasItem)
手順①: スクリプトをプロジェクトに追加
res://addons/components/ui/など、好きな場所にCooldownOverlay.gdを作成。- 上記のコードをそのままコピペして保存します。
class_name CooldownOverlayを定義しているので、どのシーンからでもノード追加ダイアログで検索できます。
手順②: ボタンシーンにコンポーネントとして追加
- クールダウンを付けたい
TextureRect(ボタン)シーンを開く。 - その
TextureRectを右クリック → 「子ノードを追加」。 - 検索欄に
CooldownOverlayと入力し、追加します。
最終的なシーン構成の例:
SkillButton (TextureRect) ├── Icon (TextureRect) └── CooldownOverlay (CanvasItem)
CooldownOverlay のインスペクタで、以下のように調整しましょう。
- cooldown_duration: 3.0(例:3秒クールダウン)
- overlay_color: (0, 0, 0, 0.6) くらいの半透明黒
- start_angle_deg: 270(上から時計回りに減る感じ)
- clockwise: チェック ON(時計回り)
- hide_when_ready: チェック ON(クールダウン中だけ表示)
手順③: スキル発動時にクールダウンを開始する
ボタン側のスクリプトから、CooldownOverlay に対して start_cooldown() を呼び出します。
例として、ボタンをクリックしたらスキルを撃ち、クールダウンが終わるまで再入力を無効にする実装はこんな感じです。
extends TextureRect
@onready var cooldown_overlay: CooldownOverlay = $CooldownOverlay
func _ready() -> void:
# クリックを拾うためにマウス入力を有効化
mouse_filter = Control.MOUSE_FILTER_STOP
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_pressed()
func _on_pressed() -> void:
# クールダウン中は無視
if cooldown_overlay.is_on_cooldown():
return
# ここでスキル発動処理を書く
_cast_skill()
# クールダウン開始(デフォルトの duration を使用)
cooldown_overlay.start_cooldown()
func _cast_skill() -> void:
print("スキル発動!")
このように、ボタン側は「いつクールダウンを開始するか」だけを意識し、
「どうやって見せるか」は CooldownOverlay に丸投げできます。
手順④: 他のUIにもペタペタ貼って再利用
同じ CooldownOverlay を、敵AIのスキルアイコンや、経験値ボーナスの再取得タイマーなどにもそのまま使えます。
例えば、画面右下に並んだ4つのスキルボタンに適用するシーン構成はこんな感じです:
SkillBar (Control)
├── Skill1 (TextureRect)
│ ├── Icon (TextureRect)
│ └── CooldownOverlay (CanvasItem)
├── Skill2 (TextureRect)
│ ├── Icon (TextureRect)
│ └── CooldownOverlay (CanvasItem)
├── Skill3 (TextureRect)
│ ├── Icon (TextureRect)
│ └── CooldownOverlay (CanvasItem)
└── Skill4 (TextureRect)
├── Icon (TextureRect)
└── CooldownOverlay (CanvasItem)
それぞれのボタンシーンでクールダウン時間だけ変えておけば、
見た目と挙動はすべて CooldownOverlay が統一してくれるので、レベルデザイン側はただ「秒数をいじるだけ」でOKです。
メリットと応用
CooldownOverlay をコンポーネントとして切り出すことで、次のようなメリットがあります。
- シーン構造がシンプル
「ボタンクラスを継承してクールダウン付きボタンを作る」ではなく、
どんなTextureRectにも子ノードとしてCooldownOverlayを追加するだけで済みます。 - 見た目とロジックの分離
ボタンの見た目(フレーム、アイコン、ホバー演出など)は自由に作りこめて、
クールダウン表示は完全に別コンポーネント。デザイナとプログラマの役割分担がしやすくなります。 - 再利用性が高い
「スキルボタン」「ポーションボタン」「ショップの更新ボタン」など、
用途が違っても同じコンポーネントをペタペタ貼るだけで使い回せます。 - 差し替えが簡単
将来「円ではなく横バーで見せたい」「テクスチャマスクで凝った演出をしたい」となっても、CooldownOverlayの実装だけ差し替えれば、全ボタンに一括で反映されます。
継承ベースで「CooldownButton」「SkillButton」「PotionButton」…とクラスが増えていく地獄より、
「ボタンはボタン」「クールダウン表示はコンポーネント」という分離のほうが、長期的にかなり楽になりますね。
改造案:クールダウン完了時にキラッと光らせる
最後に、ちょっとした改造案として、クールダウン終了時にシグナルを飛ばす実装を追加してみましょう。
ボタン側でこのシグナルを受け取って、エフェクトを再生するなどの応用ができます。
CooldownOverlay に以下を追加します:
signal cooldown_finished
func _process(delta: float) -> void:
if Engine.is_editor_hint() and preview_in_editor and not _cooldown_active:
_cooldown_time_left = cooldown_duration * 0.5
queue_redraw()
return
if not _cooldown_active:
return
_cooldown_time_left -= delta
if _cooldown_time_left <= 0.0:
_cooldown_time_left = 0.0
_cooldown_active = false
if hide_when_ready:
visible = false
emit_signal("cooldown_finished") # ★ ここで通知
queue_redraw()
ボタン側では、例えばこんな感じで受け取れます:
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
cooldown_overlay.cooldown_finished.connect(_on_cooldown_finished)
func _on_cooldown_finished() -> void:
# ここでエフェクトを再生したり、SFXを鳴らしたり
print("クールダウン完了!")
このように、コンポーネント指向で小さな責務に分けておくと、
「クールダウン完了演出」みたいな要素も後からどんどん足しやすくなります。
深いノード階層や巨大な継承ツリーに悩まされる前に、こうした小さなコンポーネントを組み合わせるスタイルにシフトしていきたいですね。
