Godot 4でUIを作っていると、スコアやコイン枚数、ダメージ数値などを「パッ」と切り替えるだけだと、どうしても味気ないですよね。
よくある実装としては、Label を継承したカスタムクラスを作って、その中で数値アニメーションをゴリゴリ書いていく方法があります。

ただ、このやり方にはいくつかモヤっとポイントがあります。

  • スコア用、HP用、コイン用…と、用途ごとに派生クラスが増えていく
  • 「このLabelだけアニメーションさせたい」と思っても、シーン構造を変えたり継承を差し替えたりが必要
  • UIデザイナーやレベルデザイナーが、シーンツリーだけ見ても「どのLabelがアニメーションするのか」が分かりにくい

そこで今回は、「Labelを継承しない」「ただ横にくっつけるだけ」で数値カウントアニメーションを付与できるコンポーネント、NumberTicker を用意しました。
親ノードに Label があれば、そこに書かれている数値を現在値から目標値までパラパラとカウントアップ/ダウンしてくれるコンポーネントです。

【Godot 4】数値UIをパラパラ演出!「NumberTicker」コンポーネント

以下が、コピペで動く NumberTicker.gd のフルコードです。
どのノードにアタッチしても動きますが、「親ノードが Label である」ことを前提にしています。


extends Node
class_name NumberTicker
## 親の Label の数値を、現在値から目標値までアニメーションさせるコンポーネント。
##
## 使い方:
## - Label ノードの子としてこのコンポーネントを追加
## - set_target_value() を呼ぶだけで、Label のテキストがパラパラと変化します。

@export var duration: float = 0.5:
	set(value):
		duration = max(value, 0.01)
## アニメーションにかける時間(秒)。
## 例) 0.5 なら 0.5 秒かけて現在値→目標値に変化。

@export var use_integer: bool = true
## true: 整数表示(スコアなど)
## false: 小数表示(HPバーの数値など)

@export var decimal_places: int = 2:
	set(value):
		decimal_places = clamp(value, 0, 6)
## use_integer = false のときの小数点以下の桁数。

@export var easing: Curve
## 補間用の Curve。未設定ならリニア補間。
## 0.0〜1.0 の時間に対して 0.0〜1.0 の値を返すカーブを想定。

@export var auto_read_initial_value: bool = true
## true: 親 Label のテキストから初期値を自動取得。
## false: start_value を手動で設定して使う想定。

@export var start_value: float = 0.0
## auto_read_initial_value = false のときに使う、初期の数値。

@export var auto_play_on_ready: bool = false
## true: _ready() 時に target_value へ自動的にカウントを開始。
## target_value はエディタ上で設定した値を使います。

@export var target_value: float = 0.0
## auto_play_on_ready = true のときの目標値。
## それ以外のときは、スクリプトから set_target_value() を呼んで更新します。

@export var format_string: String = ""
## 数値のフォーマット文字列。
## 空文字: 自動で整数/小数のフォーマットを決定。
## 例) "Score: {value}", "{value} pt", "+{value}"

var _label: Label
var _current_value: float = 0.0
var _from_value: float = 0.0
var _to_value: float = 0.0
var _elapsed: float = 0.0
var _is_ticking: bool = false

func _ready() -> void:
	# 親ノードが Label であることを前提とする
	_label = get_parent() as Label
	if _label == null:
		push_warning("NumberTicker: Parent is not a Label. This component expects its parent to be a Label.")
		return
	
	# 初期値の決定
	if auto_read_initial_value:
		_current_value = _parse_label_text(_label.text)
	else:
		_current_value = start_value
	
	_from_value = _current_value
	_to_value = target_value
	
	# 初期表示を反映
	_update_label_text(_current_value)
	
	# 自動再生
	if auto_play_on_ready:
		tick_to(target_value)

func _process(delta: float) -> void:
	if not _is_ticking:
		return
	
	_elapsed += delta
	var t := _elapsed / duration
	if t >= 1.0:
		t = 1.0
	
	# イージングカーブの適用
	if easing:
		t = easing.sample_baked(t)
	
	var value := lerp(_from_value, _to_value, t)
	_current_value = value
	_update_label_text(value)
	
	if t >= 1.0:
		_is_ticking = false

## 外部から呼び出す用: 目標値を指定してカウントを開始
func tick_to(value: float) -> void:
	if _label == null:
		push_warning("NumberTicker: No parent Label found. tick_to() is ignored.")
		return
	
	_from_value = _current_value
	_to_value = value
	_elapsed = 0.0
	_is_ticking = true

## 現在の値を即座に目標値にセットし、アニメーションをスキップ
func jump_to(value: float) -> void:
	_is_ticking = false
	_current_value = value
	_from_value = value
	_to_value = value
	_elapsed = 0.0
	_update_label_text(value)

## 現在の値を取得
func get_current_value() -> float:
	return _current_value

## Label テキストから数値をパースする簡易関数
func _parse_label_text(text: String) -> float:
	# 数字と小数点、マイナス記号だけを抽出する
	var filtered := ""
	for c in text:
		if c.is_valid_int() or c == "." or c == "-" or c == "+":
			filtered += c
	
	if filtered == "" or filtered == "+" or filtered == "-":
		return 0.0
	
	return float(filtered)

## 数値を Label のテキストに反映
func _update_label_text(value: float) -> void:
	if _label == null:
		return
	
	var display_value: String
	if use_integer:
		display_value = str(int(round(value)))
	else:
		# 小数フォーマット
		var factor := pow(10.0, decimal_places)
		var rounded := round(value * factor) / factor
		display_value = str(rounded)
	
	# format_string が空なら生値だけ表示
	if format_string == "":
		_label.text = display_value
	else:
		# {value} を display_value に置換
		_label.text = format_string.replace("{value}", display_value)

使い方の手順

ここでは、典型的な「スコア表示」と「コイン取得UI」を例に、NumberTicker の使い方をステップで見ていきましょう。

手順① シーン構成にコンポーネントを追加する

まずは、スコア表示用の Label にコンポーネントをぶら下げます。

ScoreLabel (Label)
 └── NumberTicker (Node)
  • ScoreLabel (Label): 実際に画面に表示されるテキスト
  • NumberTicker (Node): 今回作ったコンポーネントスクリプトをアタッチしたノード

NumberTicker ノードを選択し、インスペクターで以下のように設定しておきます。

  • duration: 0.4(0.4秒でカウント)
  • use_integer: ON(スコアなので整数)
  • format_string: "Score: {value}"
  • auto_read_initial_value: ON(Label の初期テキストから数値を読む)
  • auto_play_on_ready: OFF(スクリプトから制御する)

手順② スコア管理スクリプトから呼び出す

例えば、ゲーム全体のスコアを管理する GameManager があるとします。


extends Node

var score: int = 0

@onready var score_label: Label = %ScoreLabel
@onready var score_ticker: NumberTicker = %ScoreLabel/NumberTicker

func add_score(amount: int) -> void:
	score += amount
	# ここで NumberTicker に目標値を渡すだけ
	score_ticker.tick_to(score)

このように、Label 側は一切カスタムしなくてOK で、コンポーネントに対して tick_to() を呼ぶだけで演出が入ります。

手順③ コイン取得UIの例(別の Label にもそのまま使い回し)

同じコンポーネントを、コインカウント用の UI にも使い回してみましょう。

UI (CanvasLayer)
 ├── ScoreLabel (Label)
 │    └── NumberTicker (Node)    # スコア用
 └── CoinLabel (Label)
      └── NumberTicker (Node)    # コイン用(同じスクリプト)

CoinLabel 側の NumberTicker には、例えばこんな設定をします。

  • duration: 0.25(コインはサクサク増える感じ)
  • format_string: "x {value}"(アイコンの右に「x 10」みたいに表示)

コインを管理するスクリプトはこんな感じになります。


extends Node

var coins: int = 0

@onready var coin_ticker: NumberTicker = %CoinLabel/NumberTicker

func add_coin(amount: int = 1) -> void:
	coins += amount
	coin_ticker.tick_to(coins)

スコア用とコイン用で、まったく同じコンポーネントを再利用できているのがポイントですね。
「スコア用 Label を継承したクラス」「コイン用 Label を継承したクラス」みたいな継承地獄から解放されます。

手順④ HPバーなど、小数表示の例

NumberTicker は小数も扱えるので、HPバーの数値表示にも応用できます。

PlayerHUD (Control)
 ├── HpBar (TextureProgressBar)
 └── HpLabel (Label)
      └── NumberTicker (Node)
  • use_integer: OFF
  • decimal_places: 1(例: 99.5 / 100.0)
  • format_string: "HP {value}"

スクリプト側:


extends Node

var hp: float = 100.0
var max_hp: float = 100.0

@onready var hp_bar: TextureProgressBar = %HpBar
@onready var hp_ticker: NumberTicker = %HpLabel/NumberTicker

func apply_damage(amount: float) -> void:
	hp = max(hp - amount, 0.0)
	hp_bar.value = hp
	hp_ticker.tick_to(hp)

HPバーの値とラベルの数値が、どちらもスムーズに変化してくれるので、UI全体の印象がかなりリッチになります。

メリットと応用

NumberTicker をコンポーネントとして分離しておくと、Godot らしい「深いノード継承ツリー」から距離を置きつつ、UIの表現力を上げられます。

  • Label を継承しないので、既存の UI シーンをほぼそのままに、後から演出だけを付け足せる
  • 「この Label はアニメーションする」というのが、シーンツリーを見るだけで一目瞭然
  • スコア、コイン、HP、タイマーなど、用途を問わず 同じコンポーネントを再利用できる
  • フォーマット文字列や小数設定だけで、表示スタイルを簡単に変えられる

レベルデザイナーやUI担当が、「とりあえず Label を置いて、必要なら NumberTicker を子に足す」という運用にできるので、シーン構造の整理と役割分離にもかなり効いてきます。

さらに、改造していく余地もいろいろあります。例えば、カウント完了時にシグナルを飛ばすようにしておけば、「スコアが増え切ったタイミングでSEを鳴らす」「演出を連鎖させる」といったこともできます。


signal tick_completed(final_value: float)

func _process(delta: float) -> void:
	if not _is_ticking:
		return
	
	_elapsed += delta
	var t := _elapsed / duration
	if t >= 1.0:
		t = 1.0
	
	if easing:
		t = easing.sample_baked(t)
	
	var value := lerp(_from_value, _to_value, t)
	_current_value = value
	_update_label_text(value)
	
	if t >= 1.0:
		_is_ticking = false
		emit_signal("tick_completed", _current_value)

このように、コンポーネントとして分離しておけば、「シグナルを足す」「エフェクトを鳴らす」「サウンドを鳴らす」などの拡張も、Label 本体を汚さずにどんどん積み上げていけます。
継承より合成で、UIまわりもどんどん気持ちよくしていきましょう。