Godotでスコア表示を作るとき、つい label.text = str(score) と一発で数字を変えてしまいがちですよね。でも、アーケードゲームっぽい「パラパラッ」と数字が回転する演出を入れたいとき、毎回タイマーやTweenを組んで、状態管理して…とやっていると、プレイヤーやUIごとに似たようなコードが増えてしまいます。

さらに、ScoreLabel みたいな専用シーンを継承ベースで増やしていくと、「スコアも表示したいし、HPも表示したいし、でも挙動はちょっと違うし…」とクラス爆発しがちです。深いノード階層や継承地獄は、Godot 4でも避けたいところですね。

そこで今回は、「数字をパラパラとカウントアップさせる」挙動だけを独立したコンポーネントに切り出します。その名も NumberTicker(数値ドラム)
Label にアタッチするだけで、指定した数値まで自動でカウントアップしてくれるコンポーネント指向な実装を用意しました。

【Godot 4】スコアが“生きてる”感じになる!「NumberTicker」コンポーネント

このコンポーネントは、「見た目(Label)」と「数値のアニメーションロジック」を分離するのがポイントです。
Label はただの表示用、パラパラ演出は NumberTicker に任せることで、どんな UI にも同じロジックを簡単に再利用できます。

フルコード(GDScript / Godot 4)


extends Node
class_name NumberTicker
##
## NumberTicker(数値ドラム)コンポーネント
##
## - 親ノードにある Label / RichTextLabel / Button などの「text」プロパティを
##   パラパラとカウントアップさせるコンポーネントです。
## - シーンにアタッチしておき、コードから `set_target_value()` を呼ぶだけでOK。
##

@export var auto_find_label: bool = true:
	## true の場合:
	##   親ノードから自動で Label / RichTextLabel / Button を探して対象にします。
	## false の場合:
	##   `target_node_path` で明示的に指定してください。
	get:
		return auto_find_label
	set(value):
		auto_find_label = value
		# エディタ上で切り替えたときも再解決したい場合はここで処理してもよい

@export_node_path("CanvasItem") var target_node_path: NodePath:
	## カウントアップ対象のノードパス。
	## - Label
	## - RichTextLabel
	## - Button
	## など、`text` プロパティを持つノードを想定しています。
	## auto_find_label = true の場合は基本的に未設定でOKです。

@export var duration: float = 0.5:
	## カウントアップにかける時間(秒)。
	## 例: 0.5秒で 0 → 100 までパラパラと進める。

@export var step_interval: float = 0.02:
	## 数値を更新する間隔(秒)。
	## 小さいほど「カタカタ」細かく更新されます。
	## duration と組み合わせて好みのフィーリングを調整しましょう。

@export var easing: Curve:
	## 進行度(0.0~1.0)に対するイージングカーブ。
	## 未設定の場合はリニア(等速)で進行します。
	## Godot エディタで Curve リソースを作成して指定すると、
	##   例えば最初は早く、最後はゆっくり…といった演出が可能です。

@export var format_string: String = "%d":
	## 表示フォーマット。
	## 例:
	##   "%d"      → 123
	##   "%06d"    → 000123
	##   "%d pts"  → 123 pts
	##   "Score: %d" → Score: 123
	## など、`snprintf` 互換のフォーマットを使えます(%d の部分が数値に置き換わります)。

@export var start_from_current_text: bool = true:
	## true の場合:
	##   最初のカウント開始値を、現在の Label のテキストから数値として読み取ります。
	## false の場合:
	##   `current_value` の値を初期値として使います。

@export var allow_decrease: bool = true:
	## true の場合:
	##   現在値より小さい値を指定しても、逆方向にカウントします(カウントダウン)。
	## false の場合:
	##   現在値より小さい値を指定したときは、即座にその値にジャンプします。

@export var play_on_ready: bool = false:
	## true の場合:
	##   `_ready()` 時に `initial_target_value` まで自動でカウントします。
	##   タイトル画面での初期スコア演出などに使えます。

@export var initial_target_value: int = 0:
	## play_on_ready = true のときに目指す初期ターゲット値。

@export var sound_tick: AudioStream:
	## 1ステップごとに再生するSE。
	## 例: カチカチ音、コイン音など。
	## 未指定なら音は鳴りません。

@export var sound_volume_db: float = -6.0:
	## tick SE の音量(dB)。

@export var sound_pitch_random: float = 0.0:
	## tick SE のピッチのランダム幅。
	## 0.0 の場合は固定ピッチ。
	## 例: 0.1 を指定すると、0.95~1.05 くらいの範囲でランダムになります。

signal tween_started(target_value: int)
signal tween_finished(final_value: int)

var current_value: int = 0:
	## 現在の内部値。Label に表示されている数値と基本的には一致します。
	get:
		return current_value
	set(value):
		current_value = value
		_update_label_text()

var _target_value: int = 0
var _tween: Tween
var _target_node: Node
var _tick_timer: Timer
var _audio_player: AudioStreamPlayer
var _last_displayed_value: int = 0

func _ready() -> void:
	_resolve_target_node()
	_setup_tick_timer()
	_setup_audio_player()
	_init_current_value_from_text()

	if play_on_ready:
		set_target_value(initial_target_value)

func _exit_tree() -> void:
	if _tween:
		_tween.kill()
	if _tick_timer:
		_tick_timer.queue_free()
	if _audio_player:
		_audio_player.queue_free()

#========================
# パブリックAPI
#========================

func set_target_value(value: int, p_duration: float = -1.0) -> void:
	## 指定した値までカウントアップ(もしくはダウン)します。
	## p_duration を指定すると、その呼び出しに限り duration を上書きします。
	if not is_inside_tree():
		# まだツリーに入っていない場合は、即座に反映しておく
		current_value = value
		return

	_resolve_target_node()

	if not _target_node:
		push_warning("NumberTicker: target node not found. No animation will be played.")
		current_value = value
		return

	var from_value := current_value
	var to_value := value

	# 減少を許可しない場合は、その場でジャンプ
	if not allow_decrease and to_value < from_value:
		current_value = to_value
		return

	# 既存のTweenがあれば停止
	if _tween:
		_tween.kill()
	_tween = create_tween()
	_tween.set_trans(Tween.TRANS_LINEAR)
	_tween.set_ease(Tween.EASE_IN_OUT)

	var use_duration := duration if p_duration <= 0.0 else p_duration

	_last_displayed_value = from_value
	_tick_timer.stop()
	_tick_timer.wait_time = step_interval
	_tick_timer.start()

	emit_signal("tween_started", to_value)

	# 0.0〜1.0 の進行度をアニメーションさせる
	var progress := 0.0
	_tween.tween_property(self, "_progress", 1.0, use_duration).from(0.0)
	_tween.finished.connect(func():
		_tick_timer.stop()
		current_value = to_value
		emit_signal("tween_finished", current_value)
	)

	# 内部用の疑似プロパティ
	self.set("_progress", 0.0)

func jump_to_value(value: int) -> void:
	## アニメーションせず、即座に指定値にします。
	if _tween:
		_tween.kill()
	_tick_timer.stop()
	current_value = value
	emit_signal("tween_finished", current_value)

#========================
# 内部ロジック
#========================

var _progress: float = 0.0 setget _set_progress

func _set_progress(value: float) -> void:
	_progress = clampf(value, 0.0, 1.0)
	_update_value_from_progress()

func _update_value_from_progress() -> void:
	if not _target_node:
		return

	# イージングカーブが指定されていればそれを適用
	var t := _progress
	if easing:
		t = easing.sample_baked(t)

	var from_value := _last_displayed_value
	var to_value := _target_value if _tween else current_value

	# 進行度から現在値を補間(整数に丸める)
	var new_value := int(round(lerpf(from_value, to_value, t)))

	if new_value != current_value:
		current_value = new_value

func _on_tick_timer_timeout() -> void:
	## step_interval ごとに呼ばれ、表示値が変わっていればSEを鳴らします。
	if int(current_value) != int(_last_displayed_value):
		_last_displayed_value = current_value
		_play_tick_sound()

	# 進行中にターゲットへ到達していたら止める
	if _tween and not _tween.is_running():
		_tick_timer.stop()

func _update_label_text() -> void:
	if not _target_node:
		return
	# text プロパティを持つノードであれば一括で扱う
	if "text" in _target_node:
		_target_node.text = format_string % current_value

func _resolve_target_node() -> void:
	if _target_node and is_instance_valid(_target_node):
		return

	if auto_find_label:
		# 親ノードから優先的に探す
		var parent := get_parent()
		if parent:
			if "text" in parent:
				_target_node = parent
				return
			# 子ノードも走査(UIレイアウトによっては Label が子にいる場合もある)
			for child in parent.get_children():
				if "text" in child:
					_target_node = child
					return

	# 明示的に指定されている場合はこちらを優先
	if target_node_path != NodePath():
		var node := get_node_or_null(target_node_path)
		if node and "text" in node:
			_target_node = node
			return

func _setup_tick_timer() -> void:
	_tick_timer = Timer.new()
	_tick_timer.one_shot = false
	_tick_timer.wait_time = step_interval
	add_child(_tick_timer)
	_tick_timer.timeout.connect(_on_tick_timer_timeout)

func _setup_audio_player() -> void:
	_audio_player = AudioStreamPlayer.new()
	_audio_player.bus = "SFX" if AudioServer.get_bus_index("SFX") != -1 else "Master"
	add_child(_audio_player)

func _play_tick_sound() -> void:
	if not sound_tick:
		return
	_audio_player.stream = sound_tick
	_audio_player.volume_db = sound_volume_db
	if sound_pitch_random > 0.0:
		var r := randf_range(-sound_pitch_random, sound_pitch_random)
		_audio_player.pitch_scale = 1.0 + r
	else:
		_audio_player.pitch_scale = 1.0
	_audio_player.play()

func _init_current_value_from_text() -> void:
	_resolve_target_node()
	if not _target_node:
		return
	if not start_from_current_text:
		_update_label_text()
		return

	if "text" in _target_node:
		var text_val := str(_target_node.text)
		# 先頭から数字部分だけを拾う
		var digits := ""
		for c in text_val:
			if c.is_digit():
				digits += c
			elif digits != "":
				break
		if digits != "":
			current_value = int(digits)
		else:
			current_value = 0
		_update_label_text()

使い方の手順

基本的な流れは「Label を用意して NumberTicker をアタッチ → コードから set_target_value() を呼ぶ」だけです。

  1. シーンにスコア用 Label を用意する
    例として、2Dゲームのプレイヤーシーンにスコア表示を付けるケースを考えます。
    Player (CharacterBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     ├── ScoreLabel (Label)
     └── NumberTicker (Node)
        
    • ScoreLabeltext は初期値として「0」などにしておきます。
    • NumberTickerNode として追加し、このスクリプトをアタッチします。
    • auto_find_label = true のままであれば、親の中から自動で Label を見つけてくれます。
  2. NumberTicker のパラメータを設定する
    Inspector で以下を調整しましょう。
    • duration: 0.3~0.7 秒くらいが「パラパラ感」が出やすいです。
    • step_interval: 0.02~0.05 秒くらいが細かくて気持ちいいですね。
    • format_string: "%06d" にすると 000123 のようにゼロ埋め表示。
    • easing: Curve リソースを作って、最後だけ少しゆっくりにするなど。
    • sound_tick: 軽いクリック音やコイン音を設定すると気持ちよくなります。
  3. スコア加算時に NumberTicker を呼び出す
    例えばプレイヤーのスクリプト側で、敵を倒したときにスコアを加算するコードはこんな感じです。
    
    # Player.gd (例)
    
    @onready var number_ticker: NumberTicker = $NumberTicker
    
    var score: int = 0
    
    func add_score(amount: int) -> void:
    	score += amount
    	# ここで NumberTicker に「このスコアまでアニメーションして」と依頼する
    	number_ticker.set_target_value(score)
        

    これだけで、Label の表示は score に向かってパラパラとカウントアップしてくれます。

  4. 別の UI でもそのまま再利用する
    例えば、リザルト画面の総スコア表示にも同じコンポーネントを使えます。
    ResultScreen (Control)
     ├── TotalScoreLabel (Label)
     ├── BonusScoreLabel (Label)
     ├── TotalScoreTicker (NumberTicker)
     └── BonusScoreTicker (NumberTicker)
        
    • TotalScoreTickerTotalScoreLabel を自動検出(もしくは target_node_path で明示)
    • ゲーム終了時に:
      
      func show_result(final_score: int, bonus: int) -> void:
      	$TotalScoreTicker.jump_to_value(0)
      	$BonusScoreTicker.jump_to_value(0)
      	$TotalScoreTicker.set_target_value(final_score)
      	$BonusScoreTicker.set_target_value(bonus)
              

    こうしておけば、どのシーンでも「数字がパラパラする UI」を簡単に量産できます。

メリットと応用

NumberTicker コンポーネントを使う最大のメリットは、「数字のアニメーション」という振る舞いを 1 つの再利用可能な部品に閉じ込められることです。

  • シーン構造がシンプル
    Label 側にはロジックを一切書かず、「表示するだけ」に専念させられます。
    「スコア付きプレイヤー」「スコア付き敵」「スコア付きUI」みたいな継承を作らず、どこにでも NumberTicker をポン付けできるのが良いところですね。
  • 演出の一括調整が楽
    「スコアのパラパラ速度を全体的に速くしたい」「SE の音量を下げたい」といった要望が出ても、コンポーネントのパラメータを変えるだけで全シーンに反映できます。
    継承ベースでバラバラに書いてしまうと、全部探して直す羽目になります。
  • UI デザイナー/レベルデザイナーに優しい
    「ここにもスコアっぽい数字を出したい」と思ったとき、シーンツリーに Label と NumberTicker を足すだけで動くので、非プログラマでも扱いやすい構成になります。
  • スコア以外にも使える
    HP、残り時間(秒)、コンボ数、所持コイン数など、「整数で表現する何か」のほとんどに流用できます。

応用例として、「一定以上の差があるときだけ、途中をランダムに飛ばしながらカウントする」みたいな派手な演出を入れても面白いです。

例えば、値の差が大きいときだけ「ゴロゴロ回る」感じにする簡易改造案はこんな感じです。


func _update_value_from_progress() -> void:
	if not _target_node:
		return

	var t := _progress
	if easing:
		t = easing.sample_baked(t)

	var from_value := _last_displayed_value
	var to_value := _target_value if _tween else current_value

	var diff := abs(to_value - from_value)

	# 差が大きいときは、途中を少しランダムに飛ばす
	if diff > 100:
		var base := lerpf(from_value, to_value, t)
		var jitter := randf_range(-diff * 0.05, diff * 0.05)
		var new_value := int(round(base + jitter))
		new_value = clampi(new_value, min(from_value, to_value), max(from_value, to_value))
		if new_value != current_value:
			current_value = new_value
	else:
		var new_value := int(round(lerpf(from_value, to_value, t)))
		if new_value != current_value:
			current_value = new_value

こんなふうに、演出のバリエーションはすべて NumberTicker コンポーネント内で完結させておき、UI 側は「ターゲット値をセットするだけ」にしておくと、プロジェクト全体がかなり見通しよくなります。
継承より合成、Godot の UI もどんどんコンポーネント化していきましょう。