【Cocos Creator】アタッチするだけ!NumberTicker (数値カウント)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】NumberTickerの実装:アタッチするだけでLabelの数値をパラパラとカウントアップさせる汎用スクリプト

ゲーム内のスコア表示やコイン枚数の増加など、「数値が気持ちよく増えていく演出」は多くの場面で使われます。
この記事では、Label にアタッチするだけで、現在の数値から目標値までパラパラとカウントアップ/ダウンできる汎用コンポーネント「NumberTicker」を実装します。
外部の GameManager やシングルトンには一切依存せず、すべてインスペクタから数値と挙動を設定できるように設計します。


コンポーネントの設計方針

1. 機能要件の整理

  • このコンポーネントをアタッチしたノードには Label が付いていることを前提とする。
  • 現在の Label のテキスト(数値)を 開始値(startValue) として扱い、目標値(targetValue) まで数値を変化させる。
  • カウントアップ/カウントダウン両対応(startValue < targetValue なら増加、> なら減少)。
  • 変化の速度は以下の2パターンをサポート:
    • duration モード: 指定した秒数で開始値から目標値まで到達する。
    • speed モード: 1秒あたり何単位増やすか(countPerSecond)で決める。
  • 整数表示・小数表示を切り替え可能。
  • カンマ区切り(1,234,567 のようなフォーマット)をオプションでサポート。
  • 自動開始(onLoad / start で勝手に始める)と、外部からメソッド呼び出しで開始の2パターンに対応。
  • カウント中に setTarget で目標値を更新すると、その時点の値から新しい目標値へ滑らかに再スタートする。
  • Label が見つからない場合は エラーログを出して動作を止める(防御的実装)。

2. 外部依存をなくす設計アプローチ

  • GameManager やシングルトンなど、他のカスタムスクリプトには一切依存しない。
  • 必要な設定値はすべて @property で Inspector に公開し、エディタ上の設定だけで完結させる。
  • 開始タイミングや目標値の更新など、ゲームロジックから制御したい場合でも、
    • startTick()
    • stopTick()
    • setTarget(value: number, restart: boolean = true)

    のような汎用メソッドを公開し、「どこから呼んでも動作が完結する」形にする。

3. インスペクタで設定可能なプロパティ設計

NumberTicker に持たせるプロパティと役割は以下の通りです。

  • autoStart: boolean
    • 説明: true の場合、onLoad 時に自動でカウントを開始する。
    • 用途: シンプルに「シーンが開いたら勝手に数値が増える」UIに使いたいとき。
  • startFromLabelText: boolean
    • 説明: true の場合、startValue を Label の現在のテキストから自動取得する。
    • false の場合は、startValue プロパティで指定した値から開始する。
    • 用途: 既にシーン上に初期値が書いてある場合は true、スクリプト側で初期値を管理したい場合は false。
  • startValue: number
    • 説明: カウント開始時の数値。startFromLabelText = false のときのみ使用。
    • 用途: 0 からスタートしたいスコアなどに。
  • targetValue: number
    • 説明: カウントの目標値。startValue からこの値までカウントアップ/ダウンする。
    • 用途: 例)コイン獲得後の合計枚数、ステージクリア時の最終スコアなど。
  • useDuration: boolean
    • 説明: true のとき「duration モード」、false のとき「speed モード」で数値変化速度を決定する。
  • duration: number
    • 説明: duration モード時に使用。開始値から目標値まで変化するのにかける時間(秒)。
    • 例: 0.5 〜 1.0 秒程度にすると「パラパラッ」と気持ちよく変化する。
  • countPerSecond: number
    • 説明: speed モード時に使用。1秒あたりに増加(または減少)する数値量。
    • 例: 1000 にすると、1秒で 1000 ずつ増えるイメージ。
  • useInteger: boolean
    • 説明: true の場合、表示を整数に丸める(Math.round)。
    • false の場合、小数を表示する(decimalPlaces で桁数指定)。
  • decimalPlaces: number
    • 説明: 小数表示時の小数点以下桁数。0〜6 くらいを想定。
    • 例: 2 にすると「123.45」のような表示。
  • useCommaSeparator: boolean
    • 説明: true の場合、整数部に 3桁ごとのカンマ区切りを入れる(例: 1,234,567)。
    • 用途: スコアやお金など、桁数が多い数値の視認性向上。
  • playOnEnable: boolean
    • 説明: true の場合、ノードが有効化されたタイミング(onEnable)でも自動開始する。
    • 用途: ポップアップを再表示するたびに毎回アニメーションさせたい場合など。
  • resetOnDisable: boolean
    • 説明: true の場合、ノードが無効化されたタイミングでカウントを停止し、次回開始時には startValue から再スタートする。

TypeScriptコードの実装

ここからは、上記設計に基づいた完成版の TypeScript コードを示します。


import { _decorator, Component, Label, log, error } from 'cc';
const { ccclass, property } = _decorator;

/**
 * NumberTicker
 * Label の数値を現在値から目標値までパラパラとカウントアップ/ダウンさせる汎用コンポーネント。
 * 
 * 使用条件:
 * - このコンポーネントをアタッチしたノードに Label コンポーネントが付いていること。
 */
@ccclass('NumberTicker')
export class NumberTicker extends Component {

    @property({
        tooltip: 'シーン開始(onLoad)時に自動でカウントを開始するかどうか。',
    })
    public autoStart: boolean = true;

    @property({
        tooltip: 'true: Label の現在のテキストを開始値として使用する / false: startValue プロパティを使用する。',
    })
    public startFromLabelText: boolean = true;

    @property({
        tooltip: 'カウント開始時の数値。startFromLabelText = false のときのみ使用されます。',
    })
    public startValue: number = 0;

    @property({
        tooltip: 'カウントの目標値。この値に向かって増減します。',
    })
    public targetValue: number = 100;

    @property({
        tooltip: 'true: duration(秒数)で全体の時間を指定 / false: 1秒あたりの増加量(countPerSecond)で指定。',
    })
    public useDuration: boolean = true;

    @property({
        tooltip: '開始値から目標値まで変化させるのにかける時間(秒)。useDuration = true のときに使用されます。',
        min: 0.01,
        step: 0.01,
    })
    public duration: number = 0.8;

    @property({
        tooltip: '1秒あたりに増加(または減少)させる数値量。useDuration = false のときに使用されます。',
        min: 1,
        step: 1,
    })
    public countPerSecond: number = 1000;

    @property({
        tooltip: 'true: 表示を整数に丸める / false: 小数も表示する(decimalPlaces で桁数指定)。',
    })
    public useInteger: boolean = true;

    @property({
        tooltip: '小数表示時の小数点以下桁数(0〜6程度を推奨)。useInteger = false のときに使用されます。',
        min: 0,
        max: 6,
    })
    public decimalPlaces: number = 2;

    @property({
        tooltip: 'true: 整数部を3桁ごとにカンマ区切りで表示します(例: 1,234,567)。',
    })
    public useCommaSeparator: boolean = true;

    @property({
        tooltip: 'true: ノードが有効化されるたびにカウントを開始します(onEnable)。',
    })
    public playOnEnable: boolean = false;

    @property({
        tooltip: 'true: ノードが無効化されたときにカウントを停止し、次回開始時に startValue からやり直します。',
    })
    public resetOnDisable: boolean = true;

    // 内部状態
    private _label: Label | null = null;
    private _currentValue: number = 0;
    private _startValueInternal: number = 0;
    private _targetValueInternal: number = 0;
    private _elapsed: number = 0;
    private _isPlaying: boolean = false;
    private _totalDuration: number = 0; // 実際に使用される計算済み duration

    onLoad() {
        this._label = this.getComponent(Label);
        if (!this._label) {
            error('[NumberTicker] このコンポーネントを使用するには、同じノードに Label コンポーネントが必要です。');
            return;
        }

        // 初期値の決定
        if (this.startFromLabelText) {
            const parsed = this._parseLabelTextToNumber(this._label.string);
            if (parsed !== null) {
                this.startValue = parsed;
            } else {
                log('[NumberTicker] Label のテキストを数値として解釈できなかったため、startValue をそのまま使用します。');
            }
        }

        this._startValueInternal = this.startValue;
        this._targetValueInternal = this.targetValue;
        this._currentValue = this._startValueInternal;
        this._updateLabelText(this._currentValue);

        if (this.autoStart) {
            this.startTick();
        }
    }

    onEnable() {
        if (this.playOnEnable && !this._isPlaying) {
            // onLoad で autoStart 済みの場合もあるため、二重開始を防ぐ
            this.startTick();
        }
    }

    onDisable() {
        if (this.resetOnDisable) {
            this.stopTick();
            this._currentValue = this.startValue;
            this._updateLabelText(this._currentValue);
        } else {
            // 単純に一時停止
            this._isPlaying = false;
        }
    }

    update(deltaTime: number) {
        if (!this._isPlaying || !this._label) {
            return;
        }

        if (this._totalDuration <= 0) {
            // duration が 0 以下の場合は即時反映
            this._currentValue = this._targetValueInternal;
            this._updateLabelText(this._currentValue);
            this._isPlaying = false;
            return;
        }

        this._elapsed += deltaTime;
        let t = this._elapsed / this._totalDuration;

        if (t >= 1) {
            t = 1;
        }

        // 線形補間
        const newValue = this._startValueInternal + (this._targetValueInternal - this._startValueInternal) * t;
        this._currentValue = newValue;
        this._updateLabelText(this._currentValue);

        if (t >= 1) {
            this._isPlaying = false;
        }
    }

    /**
     * カウントを開始(または再開始)します。
     * startValue / targetValue(もしくは setTarget で設定した値)に基づいて内部状態をリセットします。
     */
    public startTick(): void {
        if (!this._label) {
            error('[NumberTicker] Label コンポーネントが見つからないため、startTick は実行できません。');
            return;
        }

        // 現在の値を開始値とするか、プロパティの startValue を使うかは設計による。
        // ここでは「毎回 startValue から targetValue に向かう」仕様にする。
        this._startValueInternal = this.startValue;
        this._targetValueInternal = this.targetValue;
        this._currentValue = this._startValueInternal;
        this._elapsed = 0;

        // 実際に使用する duration を計算
        this._totalDuration = this._calculateDuration(this._startValueInternal, this._targetValueInternal);

        this._updateLabelText(this._currentValue);
        this._isPlaying = true;
    }

    /**
     * カウントを停止します(現在値はそのまま保持)。
     */
    public stopTick(): void {
        this._isPlaying = false;
    }

    /**
     * 新しい目標値を設定します。
     * @param value 新しい目標値
     * @param restart true の場合、現在値から新しい目標値に向けて再スタートします。
     */
    public setTarget(value: number, restart: boolean = true): void {
        this.targetValue = value;
        this._targetValueInternal = value;

        if (!this._label) {
            error('[NumberTicker] Label コンポーネントが見つからないため、setTarget のみ実行されました。ラベルがないと表示は更新されません。');
            return;
        }

        if (restart) {
            // 現在表示中の値を開始値として再スタート
            this._startValueInternal = this._currentValue;
            this._elapsed = 0;
            this._totalDuration = this._calculateDuration(this._startValueInternal, this._targetValueInternal);
            this._isPlaying = true;
        }
    }

    /**
     * 現在値を取得します。
     */
    public getCurrentValue(): number {
        return this._currentValue;
    }

    /**
     * 内部用: Label のテキストから数値を解釈する。
     * カンマを除去し、parseFloat で解析。失敗した場合は null を返す。
     */
    private _parseLabelTextToNumber(text: string): number | null {
        if (!text) {
            return null;
        }
        // カンマや空白を除去
        const cleaned = text.replace(/,/g, '').trim();
        const num = parseFloat(cleaned);
        if (isNaN(num)) {
            return null;
        }
        return num;
    }

    /**
     * 内部用: 表示用の文字列に変換して Label に反映する。
     */
    private _updateLabelText(value: number): void {
        if (!this._label) {
            return;
        }

        let displayValue = value;

        if (this.useInteger) {
            displayValue = Math.round(displayValue);
        } else {
            const factor = Math.pow(10, this.decimalPlaces);
            displayValue = Math.round(displayValue * factor) / factor;
        }

        let text = this.useInteger
            ? displayValue.toString()
            : displayValue.toFixed(this.decimalPlaces);

        if (this.useCommaSeparator) {
            text = this._applyCommaSeparator(text);
        }

        this._label.string = text;
    }

    /**
     * 内部用: 3桁ごとにカンマ区切りを入れる。
     * 小数部がある場合も考慮する。
     */
    private _applyCommaSeparator(text: string): string {
        const parts = text.split('.');
        let intPart = parts[0];
        const decimalPart = parts.length > 1 ? '.' + parts[1] : '';

        // 先頭のマイナス記号は一旦退避
        const isNegative = intPart.startsWith('-');
        if (isNegative) {
            intPart = intPart.substring(1);
        }

        intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');

        return (isNegative ? '-' : '') + intPart + decimalPart;
    }

    /**
     * 内部用: 実際に使用する duration を計算する。
     * useDuration = true の場合は duration をそのまま使用。
     * useDuration = false の場合は countPerSecond から逆算する。
     */
    private _calculateDuration(start: number, target: number): number {
        const distance = Math.abs(target - start);
        if (distance === 0) {
            return 0;
        }

        if (this.useDuration) {
            return Math.max(this.duration, 0.01);
        } else {
            const cps = Math.max(this.countPerSecond, 1);
            return distance / cps;
        }
    }
}

コードのポイント解説

  • onLoad
    • 同じノードから Label を取得し、存在しなければ error ログを出します。
    • startFromLabelText が true の場合は、Label の文字列を数値にパースして startValue に反映します。
    • 内部の開始値/目標値/現在値を初期化し、Label に初期値を表示します。
    • autoStart が true なら startTick() を呼んでカウントを開始します。
  • onEnable / onDisable
    • playOnEnable が true のとき、ノードが有効になるたびに startTick() を呼びます。
    • resetOnDisable が true なら、無効化時にカウントを停止し、次回開始時には startValue から再スタートするように戻します。
  • update
    • _isPlaying と Label の存在を確認し、問題なければ経過時間 _elapsed を積算します。
    • _elapsed / _totalDuration から進捗率 t(0〜1)を求め、線形補間で _currentValue を計算します。
    • 補間結果を _updateLabelText でフォーマットし、Label に表示します。
    • t が 1 以上になったら、目標値に到達したとみなして _isPlaying = false にします。
  • startTick / stopTick / setTarget
    • startTick: startValue から targetValue に向かってカウントを開始します。
    • stopTick: カウントを一時停止します(現在値は保持)。
    • setTarget: 新しい目標値を設定し、オプションで「今の値から再スタート」できます。
  • フォーマット関連
    • useInteger が true の場合は四捨五入して整数表示。
    • false の場合は decimalPlaces 桁に丸めてから toFixed で文字列化。
    • useCommaSeparator が true の場合は _applyCommaSeparator で整数部にカンマを挿入。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ左下の Assets パネルで、任意のフォルダ(例: assets/scripts/ui)を選択します。
  2. 右クリックして Create → TypeScript を選択します。
  3. 作成されたファイル名を NumberTicker.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、先ほどの TypeScript コード全文を貼り付けて保存します。

2. テスト用ノード(Label)の作成

  1. Hierarchy パネルで右クリック → Create → UI → Label を選択し、新しい Label ノードを作成します。
  2. 作成された Label ノードを選択し、Inspector で以下のように設定します(例):
    • String: 0(初期表示を 0 にしたい場合)
    • Font や Alignment などは任意で調整してください。

3. NumberTicker コンポーネントのアタッチ

  1. 先ほど作成した Label ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリックします。
  3. CustomNumberTicker を選択して追加します。

4. Inspector でプロパティを設定する

Label ノードに NumberTicker がアタッチされた状態で、Inspector のプロパティを次のように設定してみましょう。

基本的なカウントアップの例

  • autoStart: true
  • startFromLabelText: true(Label の String = 0 を開始値として利用)
  • startValue: 0(今回は使われませんが、念のため 0 に)
  • targetValue: 1000
  • useDuration: true
  • duration: 0.8(0.8秒で 0 → 1000 に変化)
  • countPerSecond: 1000(今回は使われません)
  • useInteger: true
  • decimalPlaces: 2(今回は使われません)
  • useCommaSeparator: true(「1,000」と表示される)
  • playOnEnable: false
  • resetOnDisable: true

この状態でシーンを再生すると、Label の数値が 0 → 1000 にパラパラと増加していくのが確認できます。

speed モードでゆっくり増やす例

  • autoStart: true
  • startFromLabelText: false
  • startValue: 0
  • targetValue: 10000
  • useDuration: false
  • countPerSecond: 500(1秒で 500 ずつ増える)
  • useInteger: true
  • useCommaSeparator: true

この設定では、約 20 秒かけて 0 → 10000 までゆっくりカウントアップします。

小数表示の例

  • startFromLabelText: false
  • startValue: 0
  • targetValue: 3.14159
  • useDuration: true
  • duration: 1.0
  • useInteger: false
  • decimalPlaces: 3(「3.142」と表示)
  • useCommaSeparator: false

この設定では、1秒かけて 0.000 → 3.142 へと小数表示で変化します。

5. 実行中にコードから制御する例(任意)

他のスクリプトから NumberTicker を操作したい場合も、このコンポーネント単体で完結するようにメソッドを用意してあります。

例: ボタンを押したときに新しい目標値へカウントさせる


import { _decorator, Component, Node } from 'cc';
import { NumberTicker } from './NumberTicker';
const { ccclass, property } = _decorator;

@ccclass('SampleController')
export class SampleController extends Component {

    @property({ tooltip: 'NumberTicker が付いている Label ノードを指定します。' })
    public tickerNode: Node | null = null;

    private _ticker: NumberTicker | null = null;

    start() {
        if (this.tickerNode) {
            this._ticker = this.tickerNode.getComponent(NumberTicker);
        }
    }

    // 例えば Button のクリックイベントからこのメソッドを呼ぶ
    public onGainCoins() {
        if (!this._ticker) {
            return;
        }

        const current = this._ticker.getCurrentValue();
        const newTarget = current + 500; // コイン500枚追加
        this._ticker.setTarget(newTarget, true);
    }
}

上記のように、他のスクリプトから setTarget を呼ぶことで、「現在値から新しい目標値まで滑らかに再カウント」させることができます。
なお、この記事の主役は NumberTicker なので、この SampleController はあくまで応用例です。NumberTicker 自体は単体で完結しています。


まとめ

  • NumberTicker は、Label にアタッチするだけで「数値がパラパラと増減する演出」を簡単に実装できる汎用コンポーネントです。
  • 開始値・目標値・速度(duration / speed)・整数/小数表示・カンマ区切りなど、すべてインスペクタから設定可能にしました。
  • 外部の GameManager やシングルトンには一切依存せず、このスクリプト単体で完結する設計になっています。
  • スコア、コイン枚数、経験値、HPバー横の数値など、ほぼすべての「数値UI」をこのコンポーネント1つで演出強化できます。
  • さらに、startTick / stopTick / setTarget / getCurrentValue を使えば、任意のゲームロジックから柔軟に制御できるため、再利用性の高い UI コンポーネントとしてプロジェクト全体で活用できます。

まずはシンプルなスコア表示に組み込んでみて、動きを確認しつつ自分のゲームに合ったパラメータを探ってみてください。
一度使い回せる形で作っておくと、以降の UI 演出の効率が大きく向上します。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!