【Cocos Creator 3.8】BeatSyncer の実装:アタッチするだけで BGM の BPM に同期した「ビートイベント」を発行する汎用スクリプト
リズムゲームや、BGM に合わせてエフェクト・アニメーションを動かしたいとき、「今がビートのタイミングか?」を知る仕組みがあると便利です。この記事では、任意のノードにアタッチするだけで、指定した BPM に合わせて定期的に「ビートシグナル(イベント)」を発行する汎用コンポーネント BeatSyncer を実装します。
BeatSyncer は他のカスタムスクリプトに一切依存せず、このコンポーネント単体で完結します。BGM の BPM やビート分割(4分音符 / 8分音符など)をインスペクタから設定するだけで、一定周期でイベントを発行し、他のコンポーネントはそのイベントを購読して処理を行うことができます。
コンポーネントの設計方針
1. 機能要件の整理
- BPM(Beat Per Minute)を指定し、そのテンポに合わせてビートタイミングを計算する。
- 指定したビート分割(例:1.0 = 4分音符、0.5 = 8分音符)ごとに「ビート発生イベント」を発行する。
- コンポーネントをアタッチしたノード自身に対して
Node.EventTypeでカスタムイベントを発行する。 - 外部の GameManager や AudioManager に依存せず、このコンポーネント単体で動作する。
- 再生開始/停止、ビートカウンタのリセット、オフセット(曲頭の無音部分など)をインスペクタから制御できる。
- エディタ上でプレビューしやすいよう、ログ出力のオン/オフを切り替え可能にする。
BeatSyncer は「BGM 再生の実処理」は担当しません。BGM の再生は任意の方法で行い、BeatSyncer は「時間に基づいてビートを刻むメトロノーム」として機能します。BGM と BeatSyncer の開始タイミングを合わせて再生することで、リズム同期が実現できます。
2. ビート計算の基本
- 1分 = 60秒
- BPM = 1分間に何拍あるか
- 4分音符1拍の長さ(秒) =
60 / BPM - 任意のビート分割(例:0.5倍で 8分音符)ごとの間隔 =
(60 / BPM) * beatUnit
ここで beatUnit を以下のように設計します:
beatUnit = 1.0→ 4分音符ごとにイベントbeatUnit = 0.5→ 8分音符ごとにイベントbeatUnit = 0.25→ 16分音符ごとにイベントbeatUnit = 2.0→ 2分音符ごとにイベント
内部では _elapsedTime に dt を加算し、_elapsedTime >= beatInterval になったらビート発生とみなし、イベントを発行して beatInterval 分だけ時間を引きます(ドリフト防止のため、while で複数ビート分処理)。
3. インスペクタで設定可能なプロパティ
BeatSyncer は、すべての設定をインスペクタから行えるようにします。
bpm: number- 説明: 楽曲の BPM(テンポ)を指定します。
- 例: 120, 140 など。
- 0 以下の値が設定された場合は自動的に 60 に補正し、警告ログを出します。
beatUnit: number- 説明: 1.0 = 4分音符、0.5 = 8分音符、0.25 = 16分音符のように、基準拍に対する倍率を指定します。
- 例: 1.0, 0.5, 0.25, 2.0 など。
- 0 以下の場合は 1.0 に補正します。
startOnLoad: boolean- 説明: true の場合、
onLoad時に自動的にビート同期を開始します。 - 外部から手動で開始したい場合は false にします。
- 説明: true の場合、
startOffsetSeconds: number- 説明: ビート開始までの遅延時間(秒)。
- 楽曲の冒頭に無音部分やカウントインがある場合に調整します。
- 0 の場合、即時にビートカウントを開始します。
loop: boolean- 説明: true の場合、無限にビートを刻み続けます。
- false の場合、
maxBeats回ビートを発生させたら自動停止します。
maxBeats: number- 説明:
loop = falseのときに発生させる最大ビート数。 - 例: 16 であれば 16 ビート(4小節分など)で停止。
- 0 以下の値は「制限なし」として扱います(実質ループ)。
- 説明:
emitEventName: string- 説明: 発行するカスタムイベント名。
- 他のコンポーネントは
node.on(emitEventName, ...)で購読できます。 - 空文字の場合はデフォルト名
"beat"を使用します。
emitOnNode: Node | null- 説明: イベントを発行するターゲットノード。
- null の場合は、このコンポーネントがアタッチされているノード自身に発行します。
- 「ビートを受け取るオブジェクト」を明確に分けたい場合に使用します。
logBeat: boolean- 説明: true の場合、各ビート発生時に
console.logでログを出します。 - 動作確認・デバッグ用。リリースビルドでは false 推奨。
- 説明: true の場合、各ビート発生時に
4. 発行されるイベントのペイロード
BeatSyncer がビートを検出すると、指定ノードに対して以下のようなイベントを発行します。
- イベント名:
emitEventName(空なら “beat”) - イベント引数(オブジェクト):
beatIndex: number… 0 から始まるビート番号。time: number… BeatSyncer 内部の経過時間(秒)。interval: number… このビート間隔(秒)。beatUnit: number… 現在のビート分割値。bpm: number… 現在の BPM。
購読側では以下のように受け取れます(例):
// 例: 同じノード上の別コンポーネントから購読する場合
this.node.on('beat', (data: any) => {
console.log('Beat!', data.beatIndex, data.time);
}, this);
TypeScriptコードの実装
BeatSyncer.ts 全コード
import { _decorator, Component, Node, CCFloat, CCInteger, CCBoolean, CCString } from 'cc';
const { ccclass, property } = _decorator;
/**
* BeatSyncer
* 指定した BPM とビート分割に従って、一定間隔でカスタムイベントを発行するコンポーネント。
*
* - 外部の GameManager や AudioManager に依存しません。
* - BGM 再生とは独立して「メトロノーム」として機能します。
* - 他コンポーネントは node.on(emitEventName, ...) でビートを購読できます。
*/
@ccclass('BeatSyncer')
export class BeatSyncer extends Component {
@property({
type: CCFloat,
tooltip: 'BPM (Beat Per Minute)。\n例: 120, 140 など。\n0 以下の場合は自動的に 60 に補正されます。'
})
public bpm: number = 120;
@property({
type: CCFloat,
tooltip: 'ビート分割係数。\n1.0 = 4分音符, 0.5 = 8分音符, 0.25 = 16分音符, 2.0 = 2分音符 など。\n0 以下の場合は 1.0 に補正されます。'
})
public beatUnit: number = 1.0;
@property({
type: CCBoolean,
tooltip: 'true の場合、コンポーネントの onLoad 時に自動でビート同期を開始します。'
})
public startOnLoad: boolean = true;
@property({
type: CCFloat,
tooltip: 'ビート開始までのオフセット時間(秒)。\n楽曲の冒頭に無音やカウントインがある場合に使用します。'
})
public startOffsetSeconds: number = 0;
@property({
type: CCBoolean,
tooltip: 'true の場合、無限にビートを刻み続けます。\nfalse の場合、maxBeats 回ビートを発生させたら自動停止します。'
})
public loop: boolean = true;
@property({
type: CCInteger,
tooltip: 'loop が false のときに発生させる最大ビート数。\n0 以下の場合は制限なしとして扱われます。'
})
public maxBeats: number = 0;
@property({
type: CCString,
tooltip: '発行するカスタムイベント名。\n空文字の場合は "beat" が使用されます。\n購読側は node.on(この名前, (data) => {...}) で受け取れます。'
})
public emitEventName: string = 'beat';
@property({
tooltip: 'イベントを発行するターゲットノード。\nnull の場合、このコンポーネントがアタッチされているノード自身に発行します。'
})
public emitOnNode: Node | null = null;
@property({
type: CCBoolean,
tooltip: 'true の場合、各ビート発生時にログを出力します(デバッグ用)。'
})
public logBeat: boolean = false;
// 内部状態
private _isRunning: boolean = false;
private _elapsedTime: number = 0; // 開始からの経過時間(秒)
private _nextBeatTime: number = 0; // 次のビートが発生する時刻(秒)
private _beatInterval: number = 0; // ビート間隔(秒)
private _beatIndex: number = 0; // 0 から始まるビート番号
onLoad() {
// パラメータの正規化
this._normalizeParameters();
// ビート間隔を初期計算
this._recalculateBeatInterval();
// 状態リセット
this.resetBeat();
if (this.startOnLoad) {
this.startBeat();
}
}
update(dt: number) {
if (!this._isRunning) {
return;
}
// 経過時間を加算
this._elapsedTime += dt;
// startOffsetSeconds に到達するまではビートを発生させない
if (this._elapsedTime < this.startOffsetSeconds) {
return;
}
// startOffsetSeconds を基準にしたローカル時間
const localTime = this._elapsedTime - this.startOffsetSeconds;
// localTime が次のビート時刻を超えたらビート発生
while (localTime >= this._nextBeatTime && this._isRunning) {
this._emitBeatEvent();
this._beatIndex++;
this._nextBeatTime += this._beatInterval;
// ループしない & 最大ビート数に達したら停止
if (!this.loop && this.maxBeats > 0 && this._beatIndex >= this.maxBeats) {
this._isRunning = false;
if (this.logBeat) {
console.log(`[BeatSyncer] Reached maxBeats (${this.maxBeats}). Stopping.`);
}
break;
}
}
}
/**
* ビート同期を開始します。
* 外部から手動で開始したい場合に利用します。
*/
public startBeat() {
if (this._isRunning) {
return;
}
this._normalizeParameters();
this._recalculateBeatInterval();
this._isRunning = true;
if (this.logBeat) {
console.log('[BeatSyncer] Start beat. BPM:', this.bpm, 'BeatUnit:', this.beatUnit, 'Interval(sec):', this._beatInterval);
}
}
/**
* ビート同期を停止します。
*/
public stopBeat() {
if (!this._isRunning) {
return;
}
this._isRunning = false;
if (this.logBeat) {
console.log('[BeatSyncer] Stop beat.');
}
}
/**
* ビートカウンタと時間をリセットします。
* startOffsetSeconds もリセット対象(再度オフセットからカウント開始)。
*/
public resetBeat() {
this._elapsedTime = 0;
this._beatIndex = 0;
this._nextBeatTime = 0; // startOffsetSeconds 経過後、即ビートを発生させる
if (this.logBeat) {
console.log('[BeatSyncer] Reset beat state.');
}
}
/**
* BPM や beatUnit を変更した後に呼び出すことで、ビート間隔を再計算します。
*/
public refreshSettings() {
this._normalizeParameters();
this._recalculateBeatInterval();
if (this.logBeat) {
console.log('[BeatSyncer] Refresh settings. BPM:', this.bpm, 'BeatUnit:', this.beatUnit, 'Interval(sec):', this._beatInterval);
}
}
/**
* 内部用: パラメータの正規化と防御的補正。
*/
private _normalizeParameters() {
if (this.bpm <= 0) {
console.warn('[BeatSyncer] bpm must be > 0. Auto-correcting to 60.');
this.bpm = 60;
}
if (this.beatUnit <= 0) {
console.warn('[BeatSyncer] beatUnit must be > 0. Auto-correcting to 1.0.');
this.beatUnit = 1.0;
}
if (this.maxBeats < 0) {
console.warn('[BeatSyncer] maxBeats must be >= 0. Auto-correcting to 0 (no limit).');
this.maxBeats = 0;
}
if (!this.emitEventName || this.emitEventName.trim().length === 0) {
this.emitEventName = 'beat';
}
}
/**
* 内部用: BPM と beatUnit からビート間隔を再計算します。
*/
private _recalculateBeatInterval() {
const secondsPerBeat = 60.0 / this.bpm; // 4分音符の長さ
this._beatInterval = secondsPerBeat * this.beatUnit;
}
/**
* 内部用: ビートイベントを発行します。
*/
private _emitBeatEvent() {
const targetNode = this.emitOnNode ? this.emitOnNode : this.node;
if (!targetNode) {
console.error('[BeatSyncer] emitOnNode is null and this.node is invalid. Cannot emit beat event.');
return;
}
const payload = {
beatIndex: this._beatIndex,
time: this._elapsedTime,
interval: this._beatInterval,
beatUnit: this.beatUnit,
bpm: this.bpm,
};
targetNode.emit(this.emitEventName, payload);
if (this.logBeat) {
console.log(`[BeatSyncer] Beat #${this._beatIndex} emitted. Event: "${this.emitEventName}"`, payload);
}
}
}
主要メソッドの解説
onLoad()- インスペクタから設定された値を正規化(防御的補正)します。
- BPM と beatUnit からビート間隔を計算します。
- 内部状態(経過時間・ビート番号)をリセットします。
startOnLoadが true の場合、自動的にビート同期を開始します。
update(dt)_isRunningが true のときのみ処理します。_elapsedTimeにdtを加算し、startOffsetSecondsに到達するまで待機します。- オフセット経過後、ローカル時間
localTimeが_nextBeatTimeを超えるたびにビートイベントを発行します。 - ループしない設定かつ
maxBeatsに達した場合、自動的に停止します。
startBeat()- 外部から明示的に呼び出すことで、ビート同期を開始できます。
- 開始時にパラメータを再正規化し、ビート間隔を再計算します。
stopBeat()- ビート同期を停止します(
update内の処理が行われなくなります)。
- ビート同期を停止します(
resetBeat()- 経過時間とビート番号を 0 に戻し、次のビート時刻をリセットします。
startOffsetSecondsも含めて、最初からカウントし直したい場合に使用します。
refreshSettings()- インスペクタで BPM や beatUnit を変更した後に呼ぶことで、新しい設定でビート間隔を再計算します。
_emitBeatEvent()- ターゲットノード(
emitOnNodeが設定されていればそれ、なければ自ノード)に対してイベントを発行します。 - イベント名は
emitEventName(空なら “beat”)。 - ビート番号や BPM 等を含むペイロードを渡します。
logBeatが true の場合、コンソールにログを出力します。
- ターゲットノード(
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで、BeatSyncer を置きたいフォルダ(例:
assets/scripts)を右クリックします。 - Create > TypeScript を選択し、ファイル名を
BeatSyncer.tsにします。 - 作成された
BeatSyncer.tsをダブルクリックしてエディタで開き、この記事の「TypeScriptコードの実装」にあるコード全文を貼り付けて保存します。
2. テスト用ノードの作成
BeatSyncer 自体はどのノードにもアタッチ可能ですが、動作確認のために簡単なノードを用意します。
- Hierarchy パネルで右クリック → Create > 2D Object > Sprite などを選んで、任意のノード(例:
BeatTester)を作成します。 - このノードは見た目の確認用なので、Sprite でなくても空ノードでも構いません。
3. BeatSyncer コンポーネントのアタッチ
- Hierarchy で先ほど作成した
BeatTesterノードを選択します。 - Inspector パネルの下部にある Add Component ボタンをクリックします。
- Custom カテゴリから BeatSyncer を選択してアタッチします。
4. プロパティの設定例
Inspector で BeatSyncer の各プロパティを次のように設定してみます:
- bpm:
120 - beatUnit:
1.0(4分音符ごと) - startOnLoad: チェック ON(true)
- startOffsetSeconds:
0 - loop: チェック ON(true)
- maxBeats:
0(制限なし) - emitEventName:
beat(デフォルトのままで OK) - emitOnNode: 空(None)のまま(自ノードに発行)
- logBeat: チェック ON(true)
この設定では、ゲーム開始からすぐに 120 BPM(1 拍 0.5 秒)で 4分音符ごとに "beat" イベントを発行し、各ビートごとにログが出力されます。
5. ビートイベントを受け取るテストスクリプト(任意)
BeatSyncer 単体でもコンソールログで動作確認できますが、イベント購読の例として、同じノードに簡単なテストコンポーネントを追加してみます。
- Assets パネルで右クリック → Create > TypeScript で
BeatListener.tsを作成します。 - 以下のコードを貼り付けます(完全に任意です。BeatSyncer の動作には不要):
import { _decorator, Component, Node, Color, Sprite } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BeatListener')
export class BeatListener extends Component {
private _sprite: Sprite | null = null;
onLoad() {
this._sprite = this.getComponent(Sprite);
if (!this._sprite) {
console.warn('[BeatListener] Sprite component not found. Color change will be skipped.');
}
// BeatSyncer が emitEventName = 'beat' でイベントを発行している前提
this.node.on('beat', this.onBeat, this);
}
onDestroy() {
this.node.off('beat', this.onBeat, this);
}
private onBeat(data: any) {
console.log('[BeatListener] Received beat:', data);
if (this._sprite) {
// 簡単なフィードバックとして、色をランダムに変更
this._sprite.color = new Color(
Math.random() * 255,
Math.random() * 255,
Math.random() * 255,
255
);
}
}
}
この BeatListener も外部依存を持たない単純な例です。
Hierarchy で BeatTester ノードを選択し、Add Component > Custom > BeatListener を追加しておきます。
6. シーンを再生して動作確認
- エディタ上部の Play ボタンを押してゲームを再生します。
- Console パネルを開くと、0.5 秒ごとに
[BeatSyncer] Beat #0 emitted...のようなログが出ているのを確認できます。 - BeatListener を追加していれば、同じタイミングでオブジェクトの色が切り替わります。
- bpm を 60 や 180 に変えて再生し直すと、ビート間隔が変化するのが分かります。
- beatUnit を 0.5(8分音符)や 0.25(16分音符)に変えると、より細かいリズムでイベントが発行されます。
7. BGM と実際に同期させるときのコツ
BeatSyncer はあくまで「時間ベースのメトロノーム」です。BGM と完全に同期させるには:
- 楽曲の正確な BPM を把握して bpm に設定する。
- BGM 再生の開始タイミングと、BeatSyncer の
startBeat()呼び出しタイミングを合わせる。 - 楽曲冒頭に無音やカウントインがある場合、その長さ(秒)を startOffsetSeconds に設定する。
- リズムがずれて感じる場合、startOffsetSeconds を 0.01〜0.05 秒単位で微調整してみる。
Cocos Creator の標準 AudioSource コンポーネントを使って BGM を再生している場合は、BGM を再生するノードと BeatSyncer を同じタイミングで起動するか、スクリプトから両方を同時に開始するように設計すると同期しやすくなります(ただし、ここでは他スクリプトへの依存は行わないため、BeatSyncer 単体の説明に留めます)。
まとめ
この記事では、Cocos Creator 3.8 / TypeScript で、BPM に基づいて定期的にビートシグナルを発行する汎用コンポーネント BeatSyncer を実装しました。
- BeatSyncer は任意のノードにアタッチするだけで動作し、外部のカスタムスクリプトに依存しません。
- BPM・ビート分割・開始オフセット・ループ回数・イベント名などをインスペクタから柔軟に設定できます。
- 他のコンポーネントは
node.on(emitEventName, ...)でビートイベントを購読し、エフェクト・アニメーション・入力判定などをリズム同期で動かせます。 logBeatを使うことで、コンソールログだけでも簡単に動作確認ができます。
このような「時間ベースの汎用コンポーネント」をプロジェクトに一つ用意しておくと、リズムゲームだけでなく、UI アニメーションやパーティクル演出など、さまざまな場面で「テンポのある動き」を簡単に実現できます。BeatSyncer をベースに、拍子(4/4, 3/4 など)や小節ごとのイベント、シーン全体のリズム管理などに拡張していくのも有効です。
まずはこの記事のコードをそのままプロジェクトに追加し、BGM に合わせてライトを点滅させる・UI を揺らすなど、簡単な演出から試してみてください。




