【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. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create > TypeScriptを選択します。- ファイル名を
RhythmSync.tsにします。 - 作成された
RhythmSync.tsをダブルクリックし、上記のコード全文を貼り付けて保存します。
2. テスト用ノードの作成
RhythmSync 自体はどのノードにアタッチしても動作しますが、ここでは分かりやすくテスト用ノードを作成します。
- Hierarchy パネルで右クリックし、
Create > Empty Nodeを選択します。 - 作成されたノードの名前を
RhythmTesterなどに変更します。
※ このノードに Sprite やその他コンポーネントを追加しても構いませんが、RhythmSync はそれらに依存しません。
3. RhythmSync コンポーネントのアタッチ
- Hierarchy で先ほど作成した
RhythmTesterノードを選択します。 - Inspector の下部にある
Add Componentボタンをクリックします。 Custom Scriptの中からRhythmSyncを選択します。- Inspector に RhythmSync のプロパティが表示されることを確認します。
4. Inspector でのプロパティ設定例
例えば、以下のように設定してみましょう。
- bpm: 128
- autoStart: チェック ON
- startDelay: 0
- barLengthInBeats: 4
- customDivisionInBeats: 2(2 拍ごとにカスタムシグナル)
- enableDebugLog: チェック ON(動作確認用)
- maxElapsedTime: 0(まずは無制限で動作確認)
5. シーンの再生とログでの確認
- エディタ右上の
Playボタンを押してシーンを再生します。 Consoleタブを開きます。- 以下のようなログが一定間隔で出力されることを確認します。
[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 と同じノードに「ビートに合わせてスケールを変える」テスト用コンポーネントを追加してみます。
- Assets パネルで右クリック →
Create > TypeScript。 - ファイル名を
BeatScaler.tsにします。 - 以下のコードを貼り付けます。
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 自体は他のスクリプトに依存していないため、「完全な独立コンポーネント」という要件を満たしています。
- Hierarchy で
RhythmTesterノードを選択。 - Inspector →
Add Component→Custom Script→BeatScalerを追加。 - 再生すると、RhythmTester ノード(に付いている Sprite など)が ビートごとに拡大・縮小して踊るように見えるはずです。
7. BGM と合わせたい場合の運用例
このコンポーネントは BGM 再生とは直接リンクしていませんが、以下のような手順で「だいたい同期」させることができます。
- BGM 用の AudioSource コンポーネントを別ノードに追加し、曲を設定します。
- RhythmSync の
bpmを曲の BPM に合わせて設定します。 - RhythmSync の
startDelayを 0 に設定し、autoStartを ON にします。 - シーン再生直後に BGM の再生と同時に RhythmSync の
startSync()を呼ぶ(もしくは両方とも自動開始にして、微妙なズレをstartDelayで調整)。
より厳密な同期が必要な場合は、AudioSource の再生開始時間を参照して startSync() を呼ぶタイミングを合わせるなど、別スクリプト側での工夫が必要になりますが、
RhythmSync 自体はそれらに依存せず、「BPM → ビートシグナル」変換の役割に専念しています。
まとめ
- RhythmSync は、指定した BPM に基づいて ビート / 小節 / 任意拍数のシグナルを生成する、完全独立の汎用コンポーネントです。
- Inspector から BPM・小節長・開始ディレイ・カスタム区切りなどを調整でき、外部 GameManager に頼らずにビート同期の基盤を構築できます。
- 他スクリプトは
isBeatThisFrameなどのフラグを参照するだけで、敵の攻撃・背景エフェクト・UI アニメーションなどを簡単に「ビートに乗せる」ことができます。 - 防御的な実装として、不正な Inspector 値の補正や、maxElapsedTime によるカウンタリセットを備え、長時間稼働でも安定して動作します。
このコンポーネントをプロジェクト内の「リズムの基準」として配置しておけば、
あとは各コンポーネントが RhythmSync を見るだけで、ゲーム全体を簡単に音楽と同期させる設計が可能になります。
BPM さえ分かればどんな BGM にも対応できるので、リズムゲームに限らず、演出重視のアクション・パズル・UI アニメーションなど、様々なジャンルで活用できます。




