ボタンのクールダウン表示、どうしていますか?
毎回「別の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)

手順①: スクリプトをプロジェクトに追加

  1. res://addons/components/ui/ など、好きな場所に CooldownOverlay.gd を作成。
  2. 上記のコードをそのままコピペして保存します。
    class_name CooldownOverlay を定義しているので、どのシーンからでもノード追加ダイアログで検索できます。

手順②: ボタンシーンにコンポーネントとして追加

  1. クールダウンを付けたい TextureRect(ボタン)シーンを開く。
  2. その TextureRect を右クリック → 「子ノードを追加」。
  3. 検索欄に 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("クールダウン完了!")

このように、コンポーネント指向で小さな責務に分けておくと、
「クールダウン完了演出」みたいな要素も後からどんどん足しやすくなります。
深いノード階層や巨大な継承ツリーに悩まされる前に、こうした小さなコンポーネントを組み合わせるスタイルにシフトしていきたいですね。