UnityでUIを作り始めた頃、スコアやコイン枚数などの数値表示を、とりあえず Update に直接書いてしまいがちですよね。

  • ゲーム中のスコア計算
  • UIテキストの更新
  • アニメーションの補間

これらを全部ひとつのスクリプトの Update に押し込むと、すぐに「何をしているのか分からないGodクラス」が出来上がってしまいます。

特に「スコアの数値をパラパラっとカウントアップさせる演出」は、ゲームのロジックとは別にしておきたいところです。ロジックは「最終的なスコアの値」だけを決めて、UI側は「その値に向かって気持ちよくアニメーションさせる」だけに専念できると、コードもプレハブもかなりスッキリします。

そこで今回は、UIの数値を「現在値から目標値まで」自動でカウントアップ/カウントダウンしてくれる、汎用的なコンポーネント 「NumberTicker」 を作ってみましょう。

【Unity】パラパラ気持ちいい数値アニメーション!「NumberTicker」コンポーネント

このコンポーネントは、親の TextMeshProUGUI(または Text)に表示されている数値を、ターゲット値まで時間をかけてアニメーションしてくれる小さな部品です。

  • スコア表示
  • コイン枚数
  • 経験値やレベルアップ時の表示

など、数値が変化するあらゆるUIに使い回せます。

NumberTicker.cs(フルコード)


using System;
using UnityEngine;
using TMPro;              // TextMeshPro を使う場合
using UnityEngine.UI;    // Legacy Text を使う場合

/// <summary>
/// UIテキストに表示される数値を、現在値から目標値まで
/// なめらかにカウントアップ/ダウンさせるコンポーネント。
/// </summary>
[DisallowMultipleComponent]
public class NumberTicker : MonoBehaviour
{
    // --- 参照系 ----------------------------------------------------

    [Header("参照設定")]

    [Tooltip("数値を表示する TextMeshProUGUI。未設定の場合は親階層から自動検索します。")]
    [SerializeField] private TextMeshProUGUI tmpText;

    [Tooltip("レガシーな UI.Text を使う場合はこちら。未設定の場合は親階層から自動検索します。")]
    [SerializeField] private Text uiText;

    // --- 表示設定 --------------------------------------------------

    [Header("表示設定")]

    [Tooltip("整数表示フォーマット。例: \"N0\" (3桁区切り), \"0000\" (ゼロ埋め4桁) など")]
    [SerializeField] private string numberFormat = "N0";

    [Tooltip("数値の前に付けるプレフィックス。例: \"Score: \"")]
    [SerializeField] private string prefix = "";

    [Tooltip("数値の後ろに付けるサフィックス。例: \" pts\"、\" G\" など")]
    [SerializeField] private string suffix = "";

    // --- アニメーション設定 ----------------------------------------

    [Header("アニメーション設定")]

    [Tooltip("数値が変化するときにかける時間(秒)。")]
    [SerializeField] private float duration = 0.5f;

    [Tooltip("補間カーブ。0〜1の時間に対して 0〜1 の進行度を返す。")]
    [SerializeField] private AnimationCurve easeCurve =
        AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);

    [Tooltip("目標値が現在値より小さい場合も、カウントダウンするかどうか。")]
    [SerializeField] private bool allowDecrease = true;

    [Tooltip("ゲーム開始時に初期表示を行うかどうか。")]
    [SerializeField] private bool initializeOnStart = true;

    // --- 状態 ------------------------------------------------------

    /// <summary>現在表示している数値(内部的な実数値)</summary>
    private float currentValue;

    /// <summary>アニメーションの開始時の値</summary>
    private float startValue;

    /// <summary>目標値</summary>
    private float targetValue;

    /// <summary>アニメーション経過時間</summary>
    private float elapsed;

    /// <summary>現在アニメーション中かどうか</summary>
    private bool isTicking;

    /// <summary>アニメーション完了時に呼ばれるコールバック</summary>
    private Action onComplete;

    // --- ライフサイクル --------------------------------------------

    private void Reset()
    {
        // コンポーネント追加時に、親から自動でテキストを探しておく
        AutoAssignTextComponents();
    }

    private void Awake()
    {
        // 手動でアサインしていなければ、自動で探す
        AutoAssignTextComponents();
    }

    private void Start()
    {
        if (initializeOnStart)
        {
            // 既にテキストに数値が入っていれば、それを現在値として扱う
            currentValue = ParseCurrentTextOrZero();
            targetValue = currentValue;
            UpdateTextImmediate();
        }
    }

    private void Update()
    {
        if (!isTicking) return;

        elapsed += Time.unscaledDeltaTime;  // ポーズ中でも動かしたいので unscaled を使用

        float t = duration > 0f ? Mathf.Clamp01(elapsed / duration) : 1f;
        float eased = easeCurve != null ? easeCurve.Evaluate(t) : t;

        // 開始値から目標値までを補間
        currentValue = Mathf.Lerp(startValue, targetValue, eased);

        // テキストを更新(四捨五入して整数表示)
        UpdateTextImmediate();

        // 終了判定
        if (t >= 1f)
        {
            isTicking = false;
            currentValue = targetValue; // 誤差をなくすため、最終的にピタッと合わせる
            UpdateTextImmediate();

            // コールバックがあれば呼ぶ
            onComplete?.Invoke();
            onComplete = null;
        }
    }

    // --- 公開API ----------------------------------------------------

    /// <summary>
    /// 目標値を設定し、現在値からその値までアニメーションさせます。
    /// (増減どちらも対応)
    /// </summary>
    /// <param name="newTarget">目標となる整数値</param>
    /// <param name="onCompleteCallback">アニメーション完了時に呼ばれるコールバック(任意)</param>
    public void SetTarget(int newTarget, Action onCompleteCallback = null)
    {
        // 減少を禁止している場合、現在より小さい値は無視
        if (!allowDecrease && newTarget < currentValue)
        {
            // すでに現在値が目標以上なら、即座に完了扱いにする
            if (Mathf.Approximately(currentValue, newTarget) == false)
            {
                currentValue = newTarget;
                targetValue = newTarget;
                UpdateTextImmediate();
            }

            onCompleteCallback?.Invoke();
            return;
        }

        startValue = currentValue;
        targetValue = newTarget;
        elapsed = 0f;
        isTicking = true;
        onComplete = onCompleteCallback;
    }

    /// <summary>
    /// 現在の値を即座に指定値に変更し、アニメーションは行いません。
    /// </summary>
    public void SetImmediate(int value)
    {
        isTicking = false;
        currentValue = value;
        targetValue = value;
        elapsed = 0f;
        onComplete = null;
        UpdateTextImmediate();
    }

    /// <summary>
    /// 現在の整数値を取得します。
    /// </summary>
    public int GetCurrentInt()
    {
        return Mathf.RoundToInt(currentValue);
    }

    /// <summary>
    /// 目標値(最終的に到達する値)を取得します。
    /// </summary>
    public int GetTargetInt()
    {
        return Mathf.RoundToInt(targetValue);
    }

    // --- 内部処理 ---------------------------------------------------

    /// <summary>
    /// Text / TextMeshProUGUI を親階層から自動で探す。
    /// </summary>
    private void AutoAssignTextComponents()
    {
        if (tmpText == null)
        {
            tmpText = GetComponentInParent<TextMeshProUGUI>();
        }

        if (uiText == null)
        {
            uiText = GetComponentInParent<Text>();
        }
    }

    /// <summary>
    /// 現在のテキストから数値をパースする。失敗したら 0 を返す。
    /// </summary>
    private float ParseCurrentTextOrZero()
    {
        string text = null;

        if (tmpText != null)
        {
            text = tmpText.text;
        }
        else if (uiText != null)
        {
            text = uiText.text;
        }

        if (string.IsNullOrEmpty(text))
        {
            return 0f;
        }

        // prefix / suffix を取り除く
        if (!string.IsNullOrEmpty(prefix) && text.StartsWith(prefix))
        {
            text = text.Substring(prefix.Length);
        }

        if (!string.IsNullOrEmpty(suffix) && text.EndsWith(suffix))
        {
            text = text.Substring(0, text.Length - suffix.Length);
        }

        // 数値として解釈してみる
        if (int.TryParse(text, out int result))
        {
            return result;
        }

        // 失敗したら 0 扱い
        return 0f;
    }

    /// <summary>
    /// 現在の数値を、フォーマットに従って UI テキストに反映する。
    /// </summary>
    private void UpdateTextImmediate()
    {
        int displayValue = Mathf.RoundToInt(currentValue);

        string numberString;
        if (string.IsNullOrEmpty(numberFormat))
        {
            numberString = displayValue.ToString();
        }
        else
        {
            numberString = displayValue.ToString(numberFormat);
        }

        string finalText = $"{prefix}{numberString}{suffix}";

        if (tmpText != null)
        {
            tmpText.text = finalText;
        }

        if (uiText != null)
        {
            uiText.text = finalText;
        }
    }
}

使い方の手順

  1. ① UIテキストを用意する
    • Hierarchy で Canvas を作成(既にある場合はそのままでOK)。
    • Canvas の子に TextMeshPro - Text または UI - Text を追加。
    • 初期テキストは「0」など、適当な数値にしておきましょう。
  2. ② NumberTicker をアタッチする
    • テキストの GameObject か、その親の GameObject に NumberTicker を追加。
    • インスペクターで、Tmp Text または Ui Text に対象のテキストをドラッグ&ドロップ(同じ親階層にあれば、自動で見つかるので空でもOK)。
    • Prefix に「Score: 」や「x 」など、お好みの文字列を設定。
    • Suffix に「 pts」「 G」などを付けたい場合はここに設定。
    • Number Format に「N0」(3桁区切り)や「0000」(ゼロ埋め)などを指定可能です。
  3. ③ ゲーム側のスクリプトから値を更新する
    例として、プレイヤーがコインを拾うたびにスコアが増えるシンプルなスクリプトを用意します。
    
    using UnityEngine;
    
    /// <summary>プレイヤーのスコアを管理し、NumberTicker に通知する例。</summary>
    public class PlayerScore : MonoBehaviour
    {
        [SerializeField] private NumberTicker scoreTicker;
        [SerializeField] private int scorePerCoin = 10;
    
        private int score;
    
        private void Start()
        {
            // ゲーム開始時は 0 にリセット
            score = 0;
            if (scoreTicker != null)
            {
                scoreTicker.SetImmediate(score);
            }
        }
    
        // 例: コインと衝突したときに呼ばれる
        private void OnTriggerEnter(Collider other)
        {
            if (!other.CompareTag("Coin")) return;
    
            score += scorePerCoin;
    
            // NumberTicker に「新しい目標値」を渡すだけでOK
            if (scoreTicker != null)
            {
                scoreTicker.SetTarget(score);
            }
    
            Destroy(other.gameObject);
        }
    }
        
    • PlayerScore をプレイヤーオブジェクトなどにアタッチ。
    • Score Ticker に、スコア用テキストがアタッチされている NumberTicker を指定します。
    • これで、コインを拾うたびにスコアがパラパラっと増えていきます。
  4. ④ 応用例:リザルト画面で一気にカウントアップ
    ボス撃破後などに、最終スコアを「0 から最終値まで」一気にカウントアップさせる演出も簡単です。
    
    using UnityEngine;
    
    /// <summary>リザルト画面で最終スコアをカウントアップ表示する例。</summary>
    public class ResultScorePresenter : MonoBehaviour
    {
        [SerializeField] private NumberTicker scoreTicker;
        [SerializeField] private int finalScore = 123456;
    
        private void OnEnable()
        {
            // 画面が表示されたら、0 から最終スコアまでカウントアップ
            if (scoreTicker != null)
            {
                scoreTicker.SetImmediate(0);
                scoreTicker.SetTarget(finalScore, OnScoreCountComplete);
            }
        }
    
        private void OnScoreCountComplete()
        {
            Debug.Log("スコアカウント完了!");
            // ここで「次へ」ボタンを有効にしたり、演出を続けたりできます。
        }
    }
        

メリットと応用

NumberTicker を使うことで、ゲームロジックとUI演出をきれいに分離できます。

  • スコア計算やコイン枚数の管理は、専用の「スコアマネージャー」や「インベントリ」コンポーネントに集中させる。
  • UI側は「表示する値」だけを受け取り、アニメーションは NumberTicker に任せる。
  • Prefab 化しておけば、別のシーンでも同じ演出をそのまま使い回せる。

レベルデザインの観点でも、デザイナーやレベル担当が「Duration」や「EaseCurve」をインスペクターからいじるだけで、演出のテンポを調整できます。コードを書き換えずに、

  • スコアはゆっくり増やしたい
  • コインはサクサク増やしたい
  • 経験値は一瞬で増やしてテンポを崩したくない

といった違いを、プレハブ単位で簡単に作り分けられます。

また、「Prefix」「Suffix」「NumberFormat」を組み合わせることで、

  • Score: 12,345 pts
  • x 03(ゼロ埋めライフ表示)
  • Lv. 10

といった様々なスタイルの数値UIを、同じコンポーネントで共通化できます。

改造案:音を鳴らしながらカウントさせる

数値が変化している間、一定間隔でSEを鳴らすと、より気持ちいいフィードバックになります。簡単な改造例として、NumberTicker 内に次のようなメソッドを追加してみましょう。


[SerializeField] private AudioSource tickAudioSource;
[SerializeField] private float tickInterval = 0.05f; // 何秒ごとに音を鳴らすか

private float tickTimer;

/// <summary>Update 内の最後あたりで呼び出し、カウント中に効果音を鳴らす。</summary>
private void PlayTickSoundWhileAnimating()
{
    if (!isTicking || tickAudioSource == null) return;

    tickTimer += Time.unscaledDeltaTime;

    if (tickTimer >= tickInterval)
    {
        tickTimer = 0f;
        tickAudioSource.Play();
    }
}

Update() の末尾で PlayTickSoundWhileAnimating(); を呼ぶようにすれば、カウント中だけ「ピコピコ」と音を鳴らすことができます。こういった小さな機能も、専用のメソッドに切り出しておくと、あとからオフにしたり別コンポーネントに分離したりしやすいですね。

数値表示はどんなゲームにも必ずと言っていいほど登場する要素なので、こうした小さなコンポーネントを一つ持っておくと、プロジェクト全体の見通しがかなり良くなります。ぜひ自分のプロジェクト用にカスタマイズしてみてください。