【Cocos Creator】アタッチするだけ!RhythmSync (リズム同期)の実装方法【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】RhythmSync の実装:アタッチするだけで BGM の BPM に合わせた「ビートシグナル」を出す汎用スクリプト

RhythmSync は、指定した BPM(テンポ)に合わせて一定間隔で「ビートイベント」を発生させるための汎用コンポーネントです。
このコンポーネントを任意のノードにアタッチするだけで、敵 AI、背景アニメーション、UI エフェクトなどを音楽のビートに同期させるための「タイミング情報」を簡単に得られます。

この記事では、Cocos Creator 3.8.7 + TypeScript を用いて、完全に独立して動作する RhythmSync コンポーネントを実装し、Inspector から BPM・小節長・オフセットなどを調整できる設計を解説します。


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

1. 機能要件の整理

  • BPM(Beats Per Minute)を指定すると、1 拍ごとに「ビートシグナル」を出す
  • 「1 拍」だけでなく、小節(例: 4 拍ごと)や任意の拍数ごとのシグナルも出せるようにする。
  • ゲーム側はフレーム単位でポーリング(参照)するだけで、「今フレームでビートが来たか?」を簡単に判定できる。
  • 外部の GameManager や AudioManager に依存せず、このコンポーネント単体で完結する。
  • Inspector から BPM や開始ディレイなどを調整でき、曲に合わせた微調整(オフセット)も可能にする。
  • 再生開始・停止・リセットを 公開メソッド(public) として用意し、他スクリプトからも制御しやすくする。

今回は「BGM の実際の音声再生」とは直接リンクさせず、あくまで BPM ベースのタイミング生成器として設計します。
BGM 再生自体は別の AudioSource などで行い、「だいたい同時に再生ボタンを押す」か「他スクリプトから startSync() を呼ぶ」ことで合わせる運用を想定します。

2. ビートシグナルの仕様

RhythmSync は毎フレーム内部タイマーを更新し、以下のようなフラグやカウンタを提供します。

  • isBeatThisFrame: このフレームで「1 拍」のタイミングが来たかどうか。
  • isBarThisFrame: このフレームで「小節」の頭が来たかどうか。
  • isCustomDivisionThisFrame: 任意の拍数ごとのタイミングが来たかどうか。
  • currentBeatInBar: 現在の小節内で何拍目か(0〜barLengthInBeats-1)。
  • totalBeats: 開始から数えた総ビート数。
  • totalBars: 開始から数えた総小節数。
  • elapsedTime: 開始からの経過時間(秒)。

これらは public readonly なプロパティとして公開し、
他のコンポーネントは例えば以下のように利用できます。


// 他スクリプト側の例(RhythmSync と同じノードにアタッチされている想定)
update (deltaTime: number) {
    if (this.rhythmSync && this.rhythmSync.isBeatThisFrame) {
        // 1拍ごとにジャンプさせる
        this.node.setScale(1.2, 1.2, 1.2);
    }
}

なお、今回のコンポーネントは他ノードや他カスタムスクリプトへの依存を一切持たないため、
上記のように「同じノードにアタッチされた別スクリプトから参照する」か、別ノードから getComponentInChildren などで取得して利用する設計とします。

3. Inspector で設定可能なプロパティ設計

RhythmSync コンポーネントで Inspector から調整可能にするプロパティは以下の通りです。

  • BPM(bpm: number)
    – 曲のテンポ(1 分間あたりの拍数)を指定。
    – 初期値: 120
    – 正の値のみ有効。0 以下の場合は警告を出し、自動的に 120 に補正。
  • 自動開始(autoStart: boolean)
    – true の場合、start() 時に自動でビート同期を開始。
    – false の場合、他スクリプトから startSync() を呼び出すまで待機。
  • 開始ディレイ秒(startDelay: number)
    startSync() を呼んでから、実際にビートカウントを開始するまでの待ち時間(秒)。
    – BGM 再生とビート開始を微妙にずらしたいときに使用。
    – 初期値: 0
  • 小節長(barLengthInBeats: number)
    – 1 小節あたりの拍数。4/4 拍子なら 4、3/4 拍子なら 3 など。
    – 初期値: 4
    – 1 未満の場合は 4 に補正。
  • カスタム拍区切り(customDivisionInBeats: number)
    – 任意の拍数ごとのシグナルを出すための設定。
    – 例: 2 にすると 2 拍ごとに isCustomDivisionThisFrame が true になる。
    – 0 または 1 以下にすると無効(カスタム区切りを使わない)。
  • デバッグログ出力(enableDebugLog: boolean)
    – true の場合、ビート・小節・カスタム区切りのタイミングでログを表示。
    – 開発中のタイミング確認用。リリース時は OFF 推奨。
  • 経過時間のクランプ(maxElapsedTime: number)
    – 経過時間がこの値(秒)を超えた場合、内部カウンタをリセットして数値の肥大化を防止。
    – 0 以下の場合は無制限。

これらにより、Inspector からだけでテンポ・小節構造・オフセットを柔軟に調整でき、
外部のマネージャークラスなしで 「ビート同期の土台」を構築できます。


TypeScriptコードの実装

以下が完成した RhythmSync コンポーネントの全コードです。


import { _decorator, Component, game } from 'cc';
const { ccclass, property } = _decorator;

/**
 * RhythmSync
 * 指定した BPM に基づいて「ビート」「小節」「任意拍数区切り」のシグナルを生成するコンポーネント。
 * 他のスクリプトは public なフラグを参照することで、ビートに同期した処理を行える。
 */
@ccclass('RhythmSync')
export class RhythmSync extends Component {

    // === Inspector 設定項目 ===

    @property({
        tooltip: 'BPM (Beats Per Minute)。1分あたりの拍数。\n120 なら 0.5秒ごとに1拍になります。'
    })
    public bpm: number = 120;

    @property({
        tooltip: 'true の場合、start() 時に自動でビート同期を開始します。'
    })
    public autoStart: boolean = true;

    @property({
        tooltip: 'startSync() を呼んでからビートカウントを開始するまでの待ち時間(秒)。\nBGM 再生との微調整に使います。'
    })
    public startDelay: number = 0;

    @property({
        tooltip: '1 小節あたりの拍数。4/4 拍子なら 4、3/4 拍子なら 3 など。'
    })
    public barLengthInBeats: number = 4;

    @property({
        tooltip: '任意の拍数ごとにシグナルを出したい場合に設定します。\n例: 2 にすると 2 拍ごとに isCustomDivisionThisFrame が true になります。\n1 以下の値の場合は無効になります。'
    })
    public customDivisionInBeats: number = 0;

    @property({
        tooltip: 'true の場合、ビート・小節・カスタム区切りのタイミングでデバッグログを出力します。'
    })
    public enableDebugLog: boolean = false;

    @property({
        tooltip: '経過時間がこの秒数を超えた場合、内部カウンタをリセットして数値の肥大化を防ぎます。\n0 以下の場合は無制限です。'
    })
    public maxElapsedTime: number = 0;


    // === 公開ステータス(他スクリプトから参照可能) ===

    /** このフレームで 1 拍のタイミングが来たかどうか */
    public isBeatThisFrame: boolean = false;

    /** このフレームで 小節の頭(bar)のタイミングが来たかどうか */
    public isBarThisFrame: boolean = false;

    /** このフレームで カスタム拍区切り のタイミングが来たかどうか */
    public isCustomDivisionThisFrame: boolean = false;

    /** 現在の小節内での拍番号(0 〜 barLengthInBeats-1) */
    public currentBeatInBar: number = 0;

    /** 開始からの総ビート数(0 始まり) */
    public totalBeats: number = 0;

    /** 開始からの総小節数(0 始まり) */
    public totalBars: number = 0;

    /** 開始からの経過時間(秒) */
    public elapsedTime: number = 0;


    // === 内部状態 ===

    /** 1 拍の長さ(秒)。bpm から自動計算される */
    private _secondsPerBeat: number = 0.5;

    /** 同期が有効になっているかどうか */
    private _isRunning: boolean = false;

    /** startDelay 用の残り時間(秒)。0 以下であればビートカウント開始。 */
    private _delayRemaining: number = 0;

    /** 次のビートが発生するまでの残り時間(秒) */
    private _timeToNextBeat: number = 0;

    /** 小節内のビート数カウンタ(0 〜 barLengthInBeats-1) */
    private _beatIndexInBar: number = 0;

    /** カスタム区切り用のカウンタ(ビート数) */
    private _customDivisionCounter: number = 0;


    // === ライフサイクル ===

    onLoad () {
        // 防御的な初期化:不正な値の補正
        this._normalizeInspectorValues();
        this._recalculateSecondsPerBeat();

        // 初期状態のリセット
        this._resetInternalState();

        if (this.enableDebugLog) {
            console.log('[RhythmSync] onLoad - bpm:', this.bpm, 'secondsPerBeat:', this._secondsPerBeat);
        }
    }

    start () {
        // autoStart が true の場合は自動で同期開始
        if (this.autoStart) {
            this.startSync();
        }
    }

    update (deltaTime: number) {
        // 毎フレーム、まずはフラグを false にリセット
        this.isBeatThisFrame = false;
        this.isBarThisFrame = false;
        this.isCustomDivisionThisFrame = false;

        if (!this._isRunning) {
            return;
        }

        // 経過時間の更新
        this.elapsedTime += deltaTime;

        // 最大経過時間を超えた場合のリセット(オプション)
        if (this.maxElapsedTime > 0 && this.elapsedTime > this.maxElapsedTime) {
            if (this.enableDebugLog) {
                console.warn('[RhythmSync] maxElapsedTime を超えたため内部カウンタをリセットします。');
            }
            this._resetInternalState();
        }

        // startDelay が残っている間はビートカウントを開始しない
        if (this._delayRemaining > 0) {
            this._delayRemaining -= deltaTime;
            if (this._delayRemaining <= 0) {
                // ディレイ終了と同時に、次のビートまでのカウントをリセット
                this._timeToNextBeat = this._secondsPerBeat;
                if (this.enableDebugLog) {
                    console.log('[RhythmSync] startDelay 終了。ビートカウントを開始します。');
                }
            } else {
                // まだディレイ中
                return;
            }
        }

        // ビートタイマーの更新
        this._timeToNextBeat -= deltaTime;

        // 1 フレームで複数ビートをまたぐ可能性も考慮して while ループで処理
        while (this._timeToNextBeat <= 0) {
            // 超過分を次のビートタイマーに加算
            this._timeToNextBeat += this._secondsPerBeat;

            // ここで 1 拍発生
            this._onBeat();
        }
    }


    // === 公開メソッド ===

    /**
     * ビート同期を開始します。
     * Inspector の autoStart が false の場合、他スクリプトから明示的に呼び出してください。
     */
    public startSync (): void {
        this._normalizeInspectorValues();
        this._recalculateSecondsPerBeat();
        this._resetInternalState();

        this._isRunning = true;
        this._delayRemaining = this.startDelay;

        if (this.enableDebugLog) {
            console.log('[RhythmSync] startSync 呼び出し。bpm:', this.bpm, 'startDelay:', this.startDelay);
        }

        // startDelay が 0 以下の場合は即座にカウント開始
        if (this._delayRemaining <= 0) {
            this._timeToNextBeat = this._secondsPerBeat;
        }
    }

    /**
     * ビート同期を停止します(状態は保持されます)。
     * pause のようなイメージで利用できます。
     */
    public stopSync (): void {
        this._isRunning = false;
        if (this.enableDebugLog) {
            console.log('[RhythmSync] stopSync 呼び出し。');
        }
    }

    /**
     * 内部状態を完全にリセットし、同期も停止します。
     */
    public resetSync (): void {
        this._isRunning = false;
        this._resetInternalState();
        if (this.enableDebugLog) {
            console.log('[RhythmSync] resetSync 呼び出し。内部状態をリセットしました。');
        }
    }

    /**
     * BPM を変更し、即座に反映させます。
     * 実行中にテンポチェンジしたい場合に使用します。
     */
    public setBpm (newBpm: number): void {
        if (newBpm <= 0) {
            console.warn('[RhythmSync] setBpm: 0 以下の BPM は無効です。無視します。');
            return;
        }
        this.bpm = newBpm;
        this._recalculateSecondsPerBeat();
        if (this.enableDebugLog) {
            console.log('[RhythmSync] BPM を変更しました。新しい BPM:', this.bpm, 'secondsPerBeat:', this._secondsPerBeat);
        }
    }


    // === 内部処理 ===

    /** Inspector から設定された値の正当性をチェックし、必要に応じて補正する */
    private _normalizeInspectorValues (): void {
        if (this.bpm <= 0) {
            console.warn('[RhythmSync] bpm が 0 以下のため、120 に補正します。');
            this.bpm = 120;
        }
        if (this.barLengthInBeats <= 0) {
            console.warn('[RhythmSync] barLengthInBeats が 0 以下のため、4 に補正します。');
            this.barLengthInBeats = 4;
        }
        if (this.customDivisionInBeats < 0) {
            console.warn('[RhythmSync] customDivisionInBeats が負の値のため、0 に補正します。');
            this.customDivisionInBeats = 0;
        }
    }

    /** BPM から 1 拍の長さ(秒)を再計算する */
    private _recalculateSecondsPerBeat (): void {
        this._secondsPerBeat = 60.0 / this.bpm;
    }

    /** 内部カウンタ・フラグを初期状態に戻す */
    private _resetInternalState (): void {
        this.elapsedTime = 0;
        this.totalBeats = 0;
        this.totalBars = 0;
        this.currentBeatInBar = 0;

        this._timeToNextBeat = this._secondsPerBeat;
        this._beatIndexInBar = 0;
        this._customDivisionCounter = 0;

        this.isBeatThisFrame = false;
        this.isBarThisFrame = false;
        this.isCustomDivisionThisFrame = false;
    }

    /** 1 拍発生時の処理 */
    private _onBeat (): void {
        this.isBeatThisFrame = true;
        this.totalBeats++;

        // 小節内のビートカウント更新
        this.currentBeatInBar = this._beatIndexInBar;
        this._beatIndexInBar++;

        // 小節の頭判定
        if (this._beatIndexInBar >= this.barLengthInBeats) {
            this._beatIndexInBar = 0;
            this.totalBars++;
            this.isBarThisFrame = true;
        }

        // カスタム区切り判定
        if (this.customDivisionInBeats > 1) {
            this._customDivisionCounter++;
            if (this._customDivisionCounter >= this.customDivisionInBeats) {
                this._customDivisionCounter = 0;
                this.isCustomDivisionThisFrame = true;
            }
        }

        if (this.enableDebugLog) {
            let msg = `[RhythmSync] Beat! totalBeats=${this.totalBeats}, beatInBar=${this.currentBeatInBar}`;
            if (this.isBarThisFrame) {
                msg += ` | Bar! totalBars=${this.totalBars}`;
            }
            if (this.isCustomDivisionThisFrame) {
                msg += ' | CustomDivision!';
            }
            console.log(msg);
        }
    }
}

コードの主要部分の解説

  • onLoad
    – Inspector から渡された値の妥当性をチェックし、必要なら補正。
    – BPM から 1 拍の秒数(_secondsPerBeat)を計算。
    – 内部カウンタを初期化。
  • start
    autoStart が true の場合、自動で startSync() を呼んでビート同期を開始。
  • update(deltaTime)
    – 毎フレーム、isBeatThisFrame / isBarThisFrame / isCustomDivisionThisFrame を false にリセット。
    _isRunning が false なら何もしない(停止状態)。
    elapsedTime を加算し、maxElapsedTime を超えたら内部状態をリセット。
    startDelay が残っている間はビートカウントを開始せず、ディレイが終わったら _timeToNextBeat をセット。
    _timeToNextBeat を減算し、0 以下になったら _onBeat() を呼び出し、_secondsPerBeat 分だけ加算して次のビートを待つ。
  • _onBeat()
    – 1 拍ごとに呼ばれ、isBeatThisFrame = true にする。
    – 小節内の拍カウントを進め、小節の頭で isBarThisFrame = true
    customDivisionInBeats の設定に応じて、指定拍ごとに isCustomDivisionThisFrame = true
    enableDebugLog が true のときは、ビート発生のログを出力。
  • startSync / stopSync / resetSync / setBpm
    – 他スクリプトから制御するための public メソッド。
    – ゲームの状態に応じてテンポ変更や一時停止、リスタートを簡単に行える。

使用手順と動作確認

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

  1. Assets パネルで右クリックします。
  2. Create > TypeScript を選択します。
  3. ファイル名を RhythmSync.ts にします。
  4. 作成された RhythmSync.ts をダブルクリックし、上記のコード全文を貼り付けて保存します。

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

RhythmSync 自体はどのノードにアタッチしても動作しますが、ここでは分かりやすくテスト用ノードを作成します。

  1. Hierarchy パネルで右クリックし、Create > Empty Node を選択します。
  2. 作成されたノードの名前を RhythmTester などに変更します。

※ このノードに Sprite やその他コンポーネントを追加しても構いませんが、RhythmSync はそれらに依存しません。

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

  1. Hierarchy で先ほど作成した RhythmTester ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom Script の中から RhythmSync を選択します。
  4. Inspector に RhythmSync のプロパティが表示されることを確認します。

4. Inspector でのプロパティ設定例

例えば、以下のように設定してみましょう。

  • bpm: 128
  • autoStart: チェック ON
  • startDelay: 0
  • barLengthInBeats: 4
  • customDivisionInBeats: 2(2 拍ごとにカスタムシグナル)
  • enableDebugLog: チェック ON(動作確認用)
  • maxElapsedTime: 0(まずは無制限で動作確認)

5. シーンの再生とログでの確認

  1. エディタ右上の Play ボタンを押してシーンを再生します。
  2. Console タブを開きます。
  3. 以下のようなログが一定間隔で出力されることを確認します。
[RhythmSync] onLoad - bpm: 128 secondsPerBeat: 0.46875
[RhythmSync] startSync 呼び出し。bpm: 128 startDelay: 0
[RhythmSync] Beat! totalBeats=1, beatInBar=0 | Bar! totalBars=1 | CustomDivision!
[RhythmSync] Beat! totalBeats=2, beatInBar=1
[RhythmSync] Beat! totalBeats=3, beatInBar=2 | CustomDivision!
[RhythmSync] Beat! totalBeats=4, beatInBar=3 | Bar! totalBars=2
...
  • Beat! が 1 拍ごとに出ている。
  • Bar! が 4 拍ごと(barLengthInBeats=4)に出ている。
  • CustomDivision! が 2 拍ごと(customDivisionInBeats=2)に出ている。

これで、RhythmSync が BPM に基づいて正しくビートシグナルを生成していることが確認できます。

6. 他スクリプトからビートに合わせて「踊らせる」例

次に、RhythmSync と同じノードに「ビートに合わせてスケールを変える」テスト用コンポーネントを追加してみます。

  1. Assets パネルで右クリック → Create > TypeScript
  2. ファイル名を BeatScaler.ts にします。
  3. 以下のコードを貼り付けます。

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

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

    @property({
        tooltip: 'ビートが来たときに一時的に拡大する倍率。'
    })
    public beatScale: number = 1.3;

    @property({
        tooltip: '拡大から元に戻るまでの時間(秒)。'
    })
    public recoverDuration: number = 0.15;

    private _rhythm: RhythmSync | null = null;
    private _baseScale: Vec3 = new Vec3(1, 1, 1);
    private _timer: number = 0;

    onLoad () {
        this._rhythm = this.getComponent(RhythmSync);
        if (!this._rhythm) {
            console.error('[BeatScaler] 同じノードに RhythmSync コンポーネントが見つかりません。RhythmSync を追加してください。');
        }
        this._baseScale = this.node.scale.clone();
    }

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

        // ビートが来た瞬間にスケールを跳ね上げる
        if (this._rhythm.isBeatThisFrame) {
            this.node.setScale(this._baseScale.x * this.beatScale, this._baseScale.y * this.beatScale, this._baseScale.z * this.beatScale);
            this._timer = this.recoverDuration;
        }

        // 徐々に元のスケールに戻す
        if (this._timer > 0) {
            this._timer -= deltaTime;
            const t = Math.max(this._timer / this.recoverDuration, 0);
            const current = this.node.scale;
            const targetX = this._baseScale.x + (this.beatScale - 1) * this._baseScale.x * t;
            const targetY = this._baseScale.y + (this.beatScale - 1) * this._baseScale.y * t;
            const targetZ = this._baseScale.z + (this.beatScale - 1) * this._baseScale.z * t;
            this.node.setScale(targetX, targetY, targetZ);
        }
    }
}

この BeatScaler は RhythmSync に依存するため、onLoad で getComponent して存在チェックを行い、見つからなければエラーログを出しています
RhythmSync 自体は他のスクリプトに依存していないため、「完全な独立コンポーネント」という要件を満たしています。

  1. Hierarchy で RhythmTester ノードを選択。
  2. Inspector → Add ComponentCustom ScriptBeatScaler を追加。
  3. 再生すると、RhythmTester ノード(に付いている Sprite など)が ビートごとに拡大・縮小して踊るように見えるはずです。

7. BGM と合わせたい場合の運用例

このコンポーネントは BGM 再生とは直接リンクしていませんが、以下のような手順で「だいたい同期」させることができます。

  1. BGM 用の AudioSource コンポーネントを別ノードに追加し、曲を設定します。
  2. RhythmSync の bpm を曲の BPM に合わせて設定します。
  3. RhythmSync の startDelay を 0 に設定し、autoStart を ON にします。
  4. シーン再生直後に BGM の再生と同時に RhythmSync の startSync() を呼ぶ(もしくは両方とも自動開始にして、微妙なズレを startDelay で調整)。

より厳密な同期が必要な場合は、AudioSource の再生開始時間を参照して startSync() を呼ぶタイミングを合わせるなど、別スクリプト側での工夫が必要になりますが、
RhythmSync 自体はそれらに依存せず、「BPM → ビートシグナル」変換の役割に専念しています。


まとめ

  • RhythmSync は、指定した BPM に基づいて ビート / 小節 / 任意拍数のシグナルを生成する、完全独立の汎用コンポーネントです。
  • Inspector から BPM・小節長・開始ディレイ・カスタム区切りなどを調整でき、外部 GameManager に頼らずにビート同期の基盤を構築できます。
  • 他スクリプトは isBeatThisFrame などのフラグを参照するだけで、敵の攻撃・背景エフェクト・UI アニメーションなどを簡単に「ビートに乗せる」ことができます。
  • 防御的な実装として、不正な Inspector 値の補正や、maxElapsedTime によるカウンタリセットを備え、長時間稼働でも安定して動作します。

このコンポーネントをプロジェクト内の「リズムの基準」として配置しておけば、
あとは各コンポーネントが RhythmSync を見るだけで、ゲーム全体を簡単に音楽と同期させる設計が可能になります。
BPM さえ分かればどんな BGM にも対応できるので、リズムゲームに限らず、演出重視のアクション・パズル・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をコピーしました!