【Cocos Creator 3.8】SpriteAnimatorの実装:アタッチするだけでスプライトシートのフレームアニメを再生できる汎用スクリプト

このガイドでは、Animation / AnimationClip を一切使わずSprite の SpriteFrame をコードで切り替えるだけの軽量なアニメーションコンポーネント「SpriteAnimator」を実装します。
任意のノードにアタッチして、インスペクタからフレーム画像と再生設定を行うだけで、キャラクターの歩き・待機・エフェクトなどの簡易アニメーションを実現できます。


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

目的と要件

  • AnimationPlayer / AnimationClip を使わない簡易アニメーション。
  • Sprite の SpriteFrame を一定間隔で切り替えてアニメーションを表現する。
  • 他のノードやカスタムスクリプトに一切依存しない(このコンポーネント単体で完結)。
  • インスペクタから以下を柔軟に設定できる:
    • 使用するフレーム一覧
    • フレームレート / フレーム間隔
    • ループ再生の有無
    • 再生方向(順方向・逆再生)
    • 開始時に自動再生するか
    • 一度だけ再生 / 指定回数で停止
    • ランダム開始フレーム
  • 防御的実装:
    • アタッチ先ノードに Sprite コンポーネントがない場合はエラーログを出す。
    • フレーム配列が空の場合も警告を出し、update では何もしない。

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

SpriteAnimator コンポーネントのプロパティ設計は以下の通りです。

  • @property([SpriteFrame]) frames
    • アニメーションに使用する SpriteFrame の配列
    • インスペクタでドラッグ&ドロップして順番に並べる。
    • この配列の順番がアニメーションの再生順になる。
  • @property({ type: CCFloat }) frameRate
    • 1秒あたりのフレーム数(FPS)。
    • 例:12 にすると毎秒12コマで再生。
    • 内部では frameInterval = 1 / frameRate 秒でフレームを切り替える。
  • @property loop
    • ループ再生するかどうか。
    • オン:最後のフレームの次は最初のフレームに戻る。
    • オフ:最後のフレームまで再生したら停止。
  • @property autoPlayOnStart
    • start 時に自動で再生を開始するか
    • 多くのケースではオンでよい。
  • @property playOnAwake
    • onLoad(有効化直後)で再生を開始するか
    • autoPlayOnStart より早く再生したい特殊なケース用。
    • 通常は false で問題ない。
  • @property reverse
    • 逆再生するかどうか。
    • オン:フレーム配列の最後から先頭に向かって再生。
    • ループ時も逆方向のままループする。
  • @property randomStartFrame
    • 再生開始フレームをランダムにするかどうか。
    • 同じアニメを複数体に付けて、開始タイミングをずらしたいときに便利。
  • @property({ type: CCInteger }) playTimes
    • 再生回数の上限
    • 0:無制限(ループ設定に従う)。
    • 1:一度だけ再生して停止。
    • 2以上:指定回数ループしたら停止。
    • ループ OFF で 1 にすると、「1回だけ最後まで再生して停止」と同じ動き。
  • @property pauseOnDisable
    • ノードまたはコンポーネントが disabled になったときに再生を一時停止するか。
    • 有効にしておくと、UI の表示非表示に連動してアニメーションも止まる。
  • @property resumeOnEnable
    • pauseOnDisable が有効なとき、enabled に戻ったら自動で再開するか。

また、コード内でのみ扱う内部状態として以下を持ちます(インスペクタには表示しない):

  • _sprite: アタッチ先ノードの Sprite 参照。
  • _isPlaying: 現在再生中かどうか。
  • _elapsed: 前回フレーム更新からの経過時間。
  • _currentFrameIndex: 現在のフレーム番号。
  • _playedCount: 何回ループし終えたか。

TypeScriptコードの実装

以下が完成版の SpriteAnimator.ts です。


import { _decorator, Component, Sprite, SpriteFrame, CCFloat, CCInteger, log, warn, error } from 'cc';
const { ccclass, property } = _decorator;

/**
 * SpriteAnimator
 * Sprite の spriteFrame を一定間隔で切り替えて簡易アニメーションを実現するコンポーネント。
 * 他のノードやスクリプトに依存せず、このコンポーネント単体で完結します。
 */
@ccclass('SpriteAnimator')
export class SpriteAnimator extends Component {

    @property({
        type: [SpriteFrame],
        tooltip: 'アニメーションに使用する SpriteFrame の配列。\n要素の順番が再生順になります。'
    })
    public frames: SpriteFrame[] = [];

    @property({
        type: CCFloat,
        tooltip: '1秒あたりのフレーム数(FPS)。\n例: 12 にすると毎秒12コマで再生します。'
    })
    public frameRate: number = 12;

    @property({
        tooltip: 'true の場合、最後のフレームの次は最初のフレームに戻ってループ再生します。'
    })
    public loop: boolean = true;

    @property({
        tooltip: 'コンポーネントが onLoad された時点で自動的に再生を開始します。'
    })
    public playOnAwake: boolean = false;

    @property({
        tooltip: 'start が呼ばれたタイミングで自動的に再生を開始します。'
    })
    public autoPlayOnStart: boolean = true;

    @property({
        tooltip: 'true の場合、フレーム配列の最後から先頭に向かって逆再生します。'
    })
    public reverse: boolean = false;

    @property({
        tooltip: 'true の場合、再生開始時のフレームインデックスをランダムにします。'
    })
    public randomStartFrame: boolean = false;

    @property({
        type: CCInteger,
        tooltip: 'アニメーションの再生回数。\n0: 無制限(ループ設定に従う)\n1: 一度だけ再生して停止\n2以上: 指定回数ループしたら停止します。'
    })
    public playTimes: number = 0;

    @property({
        tooltip: 'ノードまたはコンポーネントが無効化されたときに、アニメーションを一時停止します。'
    })
    public pauseOnDisable: boolean = true;

    @property({
        tooltip: 'pauseOnDisable が true の場合に、再度有効化されたとき自動的に再開します。'
    })
    public resumeOnEnable: boolean = true;

    // ---- 内部状態(インスペクタには表示しない) ----
    private _sprite: Sprite | null = null;
    private _isPlaying: boolean = false;
    private _elapsed: number = 0;
    private _currentFrameIndex: number = 0;
    private _playedCount: number = 0; // ループ完了回数

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

        // frameRate の防御的チェック
        if (this.frameRate <= 0) {
            warn('[SpriteAnimator] frameRate が 0 以下です。1 に補正します。');
            this.frameRate = 1;
        }

        // 初期フレームを設定
        this._resetInternalState(true);

        if (this.playOnAwake) {
            this.play();
        }
    }

    start() {
        // onLoad で既に playOnAwake によって再生中の場合は何もしない
        if (this.autoPlayOnStart && !this._isPlaying) {
            this.play();
        }
    }

    onEnable() {
        if (this.pauseOnDisable && this.resumeOnEnable && !this._isPlaying) {
            // 無効化中に停止していた場合、再開する
            this.play();
        }
    }

    onDisable() {
        if (this.pauseOnDisable) {
            this.pause();
        }
    }

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

        if (!this._sprite) {
            // Sprite がない場合は何もできないので停止
            this._isPlaying = false;
            return;
        }

        if (!this.frames || this.frames.length === 0) {
            // フレームが設定されていない場合も何もしない
            return;
        }

        this._elapsed += deltaTime;
        const interval = 1 / this.frameRate;

        // 経過時間がフレーム間隔を超えたら次のフレームへ
        while (this._elapsed >= interval) {
            this._elapsed -= interval;
            this._stepFrame();
        }
    }

    /**
     * アニメーションを再生開始(または再開)します。
     */
    public play(reset: boolean = false) {
        if (!this._sprite) {
            error('[SpriteAnimator] Sprite コンポーネントが見つからないため、再生できません。');
            return;
        }

        if (!this.frames || this.frames.length === 0) {
            warn('[SpriteAnimator] frames が空です。アニメーションを再生できません。');
            return;
        }

        if (reset) {
            this._resetInternalState(true);
        }

        this._isPlaying = true;
    }

    /**
     * アニメーションを一時停止します。
     */
    public pause() {
        this._isPlaying = false;
    }

    /**
     * アニメーションを停止し、内部状態もリセットします。
     */
    public stop() {
        this._isPlaying = false;
        this._resetInternalState(false);
    }

    /**
     * 現在のフレームインデックスを返します。
     */
    public getCurrentFrameIndex(): number {
        return this._currentFrameIndex;
    }

    /**
     * 現在のフレームを任意のインデックスに変更します。
     * @param index 変更したいフレームインデックス
     */
    public setCurrentFrameIndex(index: number) {
        if (!this.frames || this.frames.length === 0) {
            return;
        }
        const len = this.frames.length;
        // 範囲外を防御的に補正
        const clamped = Math.max(0, Math.min(len - 1, index));
        this._currentFrameIndex = clamped;
        this._applyFrame();
    }

    /**
     * 内部状態のリセット処理。
     */
    private _resetInternalState(randomizeStart: boolean) {
        this._elapsed = 0;
        this._playedCount = 0;

        if (this.frames && this.frames.length > 0) {
            if (randomizeStart && this.randomStartFrame) {
                this._currentFrameIndex = Math.floor(Math.random() * this.frames.length);
            } else {
                this._currentFrameIndex = this.reverse ? this.frames.length - 1 : 0;
            }
            this._applyFrame();
        }
    }

    /**
     * 1フレーム進める(または戻す)処理。
     */
    private _stepFrame() {
        if (!this.frames || this.frames.length === 0) {
            return;
        }

        const len = this.frames.length;
        let nextIndex = this._currentFrameIndex + (this.reverse ? -1 : 1);

        // 終端に到達したかどうかを判定
        const reachedEnd = this.reverse ? (nextIndex < 0) : (nextIndex >= len);

        if (reachedEnd) {
            // 1ループ完了
            this._playedCount++;

            // playTimes が 0 以外で、指定回数に達していたら停止
            if (this.playTimes > 0 && this._playedCount >= this.playTimes) {
                // 最終フレームに固定
                this._currentFrameIndex = this.reverse ? 0 : len - 1;
                this._applyFrame();
                this._isPlaying = false;
                return;
            }

            if (this.loop) {
                // ループする場合は先頭(または末尾)に戻す
                this._currentFrameIndex = this.reverse ? (len - 1) : 0;
            } else {
                // ループしない場合は最終フレームで停止
                this._currentFrameIndex = this.reverse ? 0 : len - 1;
                this._applyFrame();
                this._isPlaying = false;
                return;
            }
        } else {
            // 終端に達していないので普通にインデックスを進める
            this._currentFrameIndex = nextIndex;
        }

        this._applyFrame();
    }

    /**
     * 現在のフレームインデックスに対応する SpriteFrame を Sprite に適用します。
     */
    private _applyFrame() {
        if (!this._sprite) {
            return;
        }
        if (!this.frames || this.frames.length === 0) {
            return;
        }
        const frame = this.frames[this._currentFrameIndex];
        if (!frame) {
            warn(`[SpriteAnimator] frames[${this._currentFrameIndex}] が null です。`);
            return;
        }
        this._sprite.spriteFrame = frame;
    }
}

コードのポイント解説

  • onLoad
    • Sprite コンポーネントを getComponent(Sprite) で取得。
    • 見つからない場合は error を出して利用者に明示的に知らせる。
    • frameRate が 0 以下の場合は 1 に補正。
    • _resetInternalState(true) で初期フレームを設定。
    • playOnAwake が true なら即座に play() を呼ぶ。
  • start
    • autoPlayOnStart が true かつまだ再生していない場合に play()
    • playOnAwake と両方 true の場合は onLoad の再生が優先される。
  • update
    • _isPlaying が false のときは何もしない。
    • frames が空なら何もしない。
    • _elapseddeltaTime を加算し、frameRate から算出した interval を超えたら _stepFrame() を呼ぶ。
    • while ループで、低FPSでも時間分だけフレームを進めるようにしている。
  • _stepFrame
    • reverse に応じて次のインデックスを計算。
    • 終端に達したら _playedCount を増やし、playTimes をチェック。
    • loop が true なら先頭(または末尾)に戻す。
    • loop が false なら最終フレームで停止し、_isPlaying = false
  • _resetInternalState
    • 経過時間やループ回数をリセット。
    • randomStartFrame が true なら 0〜frames.length-1 からランダムに開始フレームを決定。
    • 逆再生時はデフォルト開始フレームを末尾にする。
  • 公開メソッド
    • play(reset?: boolean):再生開始。引数 true で内部状態もリセット。
    • pause():一時停止。
    • stop():停止+内部状態リセット。
    • getCurrentFrameIndex() / setCurrentFrameIndex():現在フレームの取得・変更。

使用手順と動作確認

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

  1. エディタの Assets パネルで任意のフォルダ(例:assets/scripts)を右クリックします。
  2. Create → TypeScript を選び、ファイル名を SpriteAnimator.ts にします。
  3. 自動生成されたテンプレートコードをすべて削除し、本記事の SpriteAnimator のコードを丸ごと貼り付けて保存します。

2. テスト用スプライトノードの作成

  1. Hierarchy パネルで右クリックし、Create → 2D Object → Sprite を選択します。
  2. 作成されたノード名を分かりやすく TestSprite などに変更しておきます。
  3. Inspector で、このノードに Sprite コンポーネントが付いていることを確認します。
    • もし付いていなければ、Add Component → Renderer → Sprite で追加してください。
  4. Sprite コンポーネントの SpriteFrame に、任意の画像(スプライトシートから切り出した1コマ)を設定しておきます(後で差し替えられます)。

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

  1. TestSprite ノードを選択した状態で、Inspector の一番下にある Add Component ボタンをクリックします。
  2. Custom Component(または Custom)の中から SpriteAnimator を選択します。
    • 見つからない場合は、スクリプトの保存と TypeScript のコンパイルが終わっているか確認し、エディタを一度フォーカスし直してください。

4. アニメーション用 SpriteFrame の準備

事前に以下のような手順でフレーム画像を用意しておきます。

  1. Assets パネルに、アニメーション用のスプライトシート画像をインポート
  2. スプライトシートを選択し、Inspector → SpriteFrame の設定から Sprite Editor を開きます。
  3. 必要に応じて Auto Slice などでコマごとに分割して、複数の SpriteFrame を生成します。
  4. Assets パネル上で、分割後の SpriteFrame(例:walk_0, walk_1, walk_2…)が一覧できる状態にしておきます。

5. SpriteAnimator のプロパティ設定

TestSprite ノードの SpriteAnimator コンポーネントを選択し、以下のように設定します。

  1. Frames
    • サイズ欄の「+」ボタンを押して、アニメーションに使用するフレーム数だけ要素を増やします(例:4コマなら「Size: 4」)。
    • Assets パネルから、各 SpriteFrame(例:walk_0walk_3)を、配列の要素スロットにドラッグ&ドロップします。
    • 並び順がそのまま再生順になるので、walk_0, walk_1, walk_2, walk_3 の順に並べてください。
  2. Frame Rate
    • 例として 12 を入力します。毎秒12フレームのアニメーションになります。
    • より滑らかにしたい場合は 24〜30、ゆっくり見せたい場合は 6〜8 などに調整してください。
  3. Loop
    • 常にループさせたい場合は チェックをオンにします。
    • 1回だけ再生して止めたいエフェクトなどでは オフにします。
  4. Play On Awake
    • 通常は false のままで構いません。
    • ノードがアクティブになった瞬間から即座に再生したい場合は オンにします。
  5. Auto Play On Start
    • シーン開始時に自動再生したい場合は オン(デフォルト)。
    • コードから明示的に play() を呼び出して制御したい場合は オフにします。
  6. Reverse
    • 逆再生したい場合のみ オンにします。
    • 通常は false のままで問題ありません。
  7. Random Start Frame
    • 同じアニメを複数体に付けて、開始タイミングをずらしたいときは オンにします。
    • 1体だけのテストでは false のままで構いません。
  8. Play Times
    • ループ回数を制限しない場合は 0 のままにします。
    • 1回だけ再生して停止したい場合は 1 を設定し、Loop をオフにするのがおすすめです。
    • 2回だけループさせたい場合は 2 を設定します。
  9. Pause On Disable / Resume On Enable
    • UI の表示非表示に合わせてアニメーションも止めたい場合は両方 オン
    • ノードの有効 / 無効に関係なくアニメを進めたい場合は Pause On Disable をオフにします。

6. 再生確認

  1. シーンを保存し、エディタ右上の ▶(再生ボタン) でプレビューを開始します。
  2. シーンが再生されると、TestSprite の画像が設定したフレーム順にパラパラと切り替わり、アニメーションしていることを確認できます。
  3. 再生中に Inspector から frameRateloop を変更すると、その場で挙動が変化するので、好みの速度・ループ設定を探ってみてください。

7. 応用的な使い方の例

  • ダメージエフェクト
    • 爆発やヒットエフェクトなど、一度だけ再生して消えるアニメに最適。
    • 設定例:
      • frames: 爆発の連番フレーム
      • frameRate: 20〜30
      • loop: false
      • playTimes: 1
  • 待機モーション
    • キャラクターの idle アニメなど、常時ループさせたいモーションに。
    • 設定例:
      • frames: idle の連番フレーム
      • frameRate: 8〜12
      • loop: true
      • playTimes: 0
  • 複数体のランダムアニメ
    • 同じアニメを持つ敵キャラを大量に配置しつつ、動きのタイミングをずらしたい場合。
    • randomStartFrame をオンにすることで、各個体の開始フレームがランダムになり、自然な見た目になります。

まとめ

SpriteAnimator コンポーネントは、

  • AnimationPlayer を使わずに軽量なフレームアニメーションを実現できる
  • Sprite コンポーネントさえあれば他に何もいらない完全独立スクリプト
  • インスペクタからフレーム・速度・ループ・逆再生・再生回数などを柔軟に調整可能

という特徴を持っています。

シンプルな 2D ゲームでは、「大げさなアニメーションシステムを使うほどでもないけれど、ちょっとした動きをつけたい」という場面が頻出します。
そうしたケースで、この SpriteAnimator.ts をアタッチするだけで簡易アニメが完結するため、プロトタイピングから本番開発まで、使い回しやすい汎用コンポーネントとして活躍します。

このままでも十分実用的ですが、必要に応じて「イベントコールバック(再生完了時に通知)」や「速度のランタイム変更 UI」といった機能を追加していくことで、自分のプロジェクトに最適化されたアニメーション制御コンポーネントへと発展させていけるはずです。