【Cocos Creator 3.8】TypingAudio の実装:アタッチするだけで「文字送りに同期したポポポ音再生」を実現する汎用スクリプト
ノベルゲームや会話シーンで、文字が一文字ずつ表示されるタイミングに合わせて「ポポポ」と効果音を鳴らしたい場面はとても多いです。
この記事では、任意のダイアログ処理から簡単に呼び出せて、アタッチするだけで使える「文字送り音」専用コンポーネント TypingAudio を実装します。
外部の GameManager などには一切依存せず、このコンポーネント単体で完結する設計にします。
コンポーネントの設計方針
1. 機能要件の整理
- ダイアログで文字が 1 文字出るタイミングに合わせて「ポポポ」音を再生する。
- 再生トリガーは「外部からメソッドを呼ぶ」だけで完結する(ダイアログ側はこのメソッドを呼ぶだけ)。
- 一定間隔よりも短い頻度では鳴らさない(高速な文字送り時に音がうるさくなりすぎないようにする)。
- 連続で鳴らすときに音が重なりすぎないよう、必要に応じて「前の音を止めてから再生」や「同時再生数の制限」ができる。
- インスペクタから音量や再生間隔などを調整できる。
- 外部のカスタムスクリプトには依存せず、AudioSource などの標準コンポーネントだけを使用する。
2. 外部依存をなくすための設計アプローチ
- 再生用メソッドを公開:
playOnType()を public メソッドとして用意し、ダイアログ側から 1 文字表示ごとに呼ぶだけで音が鳴るようにする。- TypingAudio がダイアログの状態を知る必要はない。
- AudioSource は自ノードに持つ:
- TypingAudio がアタッチされたノードに
AudioSourceを持たせる。 - 存在しない場合は
getComponent(AudioSource)で取得を試み、無ければ警告を出す。
- TypingAudio がアタッチされたノードに
- インスペクタから全て設定可能:
- 効果音クリップ、最小再生間隔、音量、ピッチ、同時再生数などを
@propertyで公開する。
- 効果音クリップ、最小再生間隔、音量、ピッチ、同時再生数などを
3. インスペクタで設定可能なプロパティ設計
TypingAudio コンポーネントに持たせるプロパティとその役割は以下の通りです。
- audioSource (AudioSource)
- このコンポーネントが利用する
AudioSourceへの参照。 - 未設定の場合は
onLoadで自ノードから自動取得を試みる。
- このコンポーネントが利用する
- typingClip (AudioClip)
- 文字送り時に再生する「ポポポ」音のクリップ。
- 必須。未設定の場合はエラーログを出して再生をスキップする。
- minInterval (number)
- 連続して音を鳴らす際の最小間隔(秒)。
- 例:0.03〜0.08 あたりがおすすめ。高速文字送り時の「音鳴りすぎ」を防ぐ。
- volume (number)
- 文字送り音の音量(0〜1)。
- AudioSource の volume を一時的に上書きして再生する。
- pitch (number)
- 再生ピッチ(1 が通常)。
- 若干高め(1.05〜1.2)にすると軽い「ポポポ」感が出やすい。
- randomizePitch (boolean)
- 有効にすると、毎回少しだけピッチをランダムにずらして再生する。
- 同じ音でもわずかに変化をつけて耳障りを軽減する。
- randomPitchRange (number)
randomizePitch有効時、ピッチを ±この値だけランダムに変動させる。- 例:0.05 なら、
pitch ± 0.05の範囲で揺らぐ。
- interruptPrevious (boolean)
- true: 前の再生が残っていても
stop()してから再度再生する。 - false: AudioSource の仕様に従う(通常は前の音がフェードアウトするような挙動)。
- true: 前の再生が残っていても
- maxSimultaneous (number)
- 同時再生数の上限。
interruptPreviousが false の場合の安全装置。 - 上限に達した場合は新しい再生をスキップする。
- 1 にするとほぼ「1音だけ」を保つ挙動になる。
- 同時再生数の上限。
- debugLog (boolean)
- 挙動をコンソールにログ出力するかどうか。
- 音が鳴らないときの原因調査用。
これらをまとめて、ダイアログ側からは playOnType() だけを呼べば良いように実装します。
TypeScriptコードの実装
import { _decorator, Component, AudioSource, AudioClip, sys } from 'cc';
const { ccclass, property } = _decorator;
/**
* TypingAudio
*
* ダイアログの「1文字表示」に合わせて呼び出すことで
* 文字送り音(ポポポ)を再生する汎用コンポーネント。
*
* 使い方:
* 1. ノードにこのコンポーネントをアタッチ
* 2. AudioSource と AudioClip を設定
* 3. ダイアログ処理から this.typingAudio.playOnType() を呼ぶ
*/
@ccclass('TypingAudio')
export class TypingAudio extends Component {
@property({
type: AudioSource,
tooltip: '文字送り音を再生する AudioSource。\n未設定の場合は、このノードから自動取得を試みます。'
})
public audioSource: AudioSource | null = null;
@property({
type: AudioClip,
tooltip: '文字送り時に再生する AudioClip(ポポポ音など)。\n必ず設定してください。'
})
public typingClip: AudioClip | null = null;
@property({
tooltip: '連続して音を鳴らす際の最小間隔(秒)。\n高速な文字送り時に音が鳴りすぎるのを防ぎます。'
})
public minInterval: number = 0.05;
@property({
tooltip: '文字送り音の音量(0.0 ~ 1.0)。\nAudioSource の volume を一時的に上書きして使用します。'
})
public volume: number = 0.6;
@property({
tooltip: '再生ピッチ。1.0 が標準で、値を上げると高い音になります。'
})
public pitch: number = 1.0;
@property({
tooltip: '有効にすると、毎回ピッチを少しだけランダムに変動させます。'
})
public randomizePitch: boolean = false;
@property({
tooltip: 'randomizePitch が有効なとき、ピッチを ±この値の範囲でランダムに変動させます。'
})
public randomPitchRange: number = 0.05;
@property({
tooltip: 'true の場合、前回の再生が残っていても stop() してから再生します。'
})
public interruptPrevious: boolean = true;
@property({
tooltip: '同時再生数の上限。interruptPrevious が false の場合の安全装置として使います。\n1 にすると常に 1 音だけが鳴るような挙動になります。'
})
public maxSimultaneous: number = 3;
@property({
tooltip: 'デバッグログをコンソールに出力するかどうか。問題があるときだけ有効にしてください。'
})
public debugLog: boolean = false;
// 内部状態管理用
private _lastPlayTime: number = -9999;
private _playingCount: number = 0;
onLoad() {
// AudioSource が未指定なら自ノードから取得を試みる
if (!this.audioSource) {
const found = this.getComponent(AudioSource);
if (found) {
this.audioSource = found;
if (this.debugLog) {
console.log('[TypingAudio] AudioSource を自ノードから自動取得しました。');
}
} else {
console.warn('[TypingAudio] AudioSource が見つかりません。このノードに AudioSource コンポーネントを追加してください。');
}
}
// 数値プロパティの防御的クランプ
if (this.minInterval < 0) {
console.warn('[TypingAudio] minInterval が 0 未満だったため 0 に補正しました。');
this.minInterval = 0;
}
if (this.volume < 0) {
console.warn('[TypingAudio] volume が 0 未満だったため 0 に補正しました。');
this.volume = 0;
}
if (this.volume > 1) {
console.warn('[TypingAudio] volume が 1 より大きかったため 1 に補正しました。');
this.volume = 1;
}
if (this.maxSimultaneous < 1) {
console.warn('[TypingAudio] maxSimultaneous は 1 以上である必要があるため 1 に補正しました。');
this.maxSimultaneous = 1;
}
if (this.randomPitchRange < 0) {
console.warn('[TypingAudio] randomPitchRange が 0 未満だったため 0 に補正しました。');
this.randomPitchRange = 0;
}
}
/**
* ダイアログ側から「1文字表示されたタイミング」で呼び出すメソッド。
*
* 例:
* // ダイアログスクリプト側
* this.typingAudio.playOnType();
*/
public playOnType() {
// AudioSource / Clip の存在チェック
if (!this.audioSource) {
console.warn('[TypingAudio] AudioSource が設定されていないため、音を再生できません。');
return;
}
if (!this.typingClip) {
console.warn('[TypingAudio] typingClip が設定されていないため、音を再生できません。');
return;
}
const now = this._getTime();
// 最小再生間隔のチェック
if (now - this._lastPlayTime < this.minInterval) {
if (this.debugLog) {
console.log('[TypingAudio] minInterval により再生をスキップしました。');
}
return;
}
// 同時再生数のチェック
if (!this.interruptPrevious && this._playingCount >= this.maxSimultaneous) {
if (this.debugLog) {
console.log('[TypingAudio] maxSimultaneous により再生をスキップしました。');
}
return;
}
// 実際の再生処理
this._playInternal(now);
}
/**
* 実際の AudioSource 再生処理
*/
private _playInternal(now: number) {
if (!this.audioSource || !this.typingClip) {
return;
}
// 前の音を止める設定なら stop()
if (this.interruptPrevious) {
this.audioSource.stop();
this._playingCount = 0;
}
// ピッチの決定
let pitchToUse = this.pitch;
if (this.randomizePitch && this.randomPitchRange > 0) {
const r = (Math.random() * 2 - 1) * this.randomPitchRange; // -range ~ +range
pitchToUse += r;
}
// AudioSource に設定を反映
this.audioSource.clip = this.typingClip;
this.audioSource.volume = this.volume;
this.audioSource.pitch = pitchToUse;
// 再生
this.audioSource.play();
this._lastPlayTime = now;
this._playingCount++;
if (this.debugLog) {
console.log(`[TypingAudio] 再生: volume=${this.volume.toFixed(2)}, pitch=${pitchToUse.toFixed(2)}, playingCount=${this._playingCount}`);
}
// 再生終了を検知して _playingCount を減らすための簡易ポーリング
// Cocos 3.8 の AudioSource には完了コールバックがないため、
// 次フレーム以降の update で監視する方式にしています。
}
update(deltaTime: number) {
// playingCount のざっくり管理:
// AudioSource が再生していない状態なら playingCount を 0 にリセットする。
if (this.audioSource && !this.audioSource.playing && this._playingCount !== 0) {
this._playingCount = 0;
if (this.debugLog) {
console.log('[TypingAudio] 再生終了を検知して playingCount をリセットしました。');
}
}
}
/**
* 現在時刻を秒単位で返すユーティリティ。
* ブラウザ・ネイティブどちらでも使えるように sys.now() を使用。
*/
private _getTime(): number {
// sys.now() はミリ秒なので秒に変換
return sys.now() / 1000;
}
}
コードのポイント解説
- onLoad()
audioSourceがインスペクタで未指定の場合、this.getComponent(AudioSource)で自ノードから自動取得します。- 数値プロパティ(
minInterval,volume,maxSimultaneous,randomPitchRange)に対して防御的な補正を行います。
- playOnType()
- ダイアログ側から呼ぶメインの API です。
- AudioSource / AudioClip の存在チェック、最小再生間隔チェック、同時再生数チェックを行った上で、
_playInternal()を呼びます。
- _playInternal()
interruptPreviousが true の場合、audioSource.stop()で前の音を止めてから再生します。- ピッチは
pitchとrandomizePitch/randomPitchRangeを元に決定します。 - AudioSource に
clip,volume,pitchを設定してplay()を呼びます。 _lastPlayTimeと_playingCountを更新します。
- update()
- Cocos 3.8 の AudioSource には「再生終了コールバック」がないため、
update内でaudioSource.playingを監視しています。 - 再生が終わっていると判断したら
_playingCountを 0 にリセットします。
- Cocos 3.8 の AudioSource には「再生終了コールバック」がないため、
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ下部の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - そのフォルダで右クリック → Create → TypeScript を選択します。
- 新しく作成されたファイル名を
TypingAudio.tsに変更します。 TypingAudio.tsをダブルクリックして開き、テンプレートコードをすべて削除し、先ほど紹介した 全コード を貼り付けて保存します。
2. テスト用ノードの作成
まずは単純に「キー入力で 1 文字出た」と仮定して音が鳴るかを確認します。
- Hierarchy パネルで右クリック → Create → Empty Node を選択し、名前を
TypingAudioTesterなどに変更します。 TypingAudioTesterノードを選択した状態で、右側の Inspector を確認します。- Add Component ボタンをクリック → Audio → AudioSource を選択します。
- これでノードに
AudioSourceコンポーネントが追加されます。
- これでノードに
- もう一度 Add Component → Custom → TypingAudio を選択して、
TypingAudioコンポーネントをアタッチします。
3. TypingAudio のプロパティ設定
Inspector 上で TypingAudio コンポーネントの各プロパティを設定します。
- audioSource:
- 空欄のままでも構いません。
同じノードに AudioSource があるため、onLoad 時に自動取得されます。 - 明示的に設定したい場合は、ドラッグ&ドロップで同じノードの AudioSource を入れてください。
- 空欄のままでも構いません。
- typingClip:
- 事前にインポートしておいた「ポポポ音」の
AudioClipを、ここにドラッグ&ドロップします。 - 未設定だと音は鳴らず、コンソールに警告が出ます。
- 事前にインポートしておいた「ポポポ音」の
- minInterval:
- まずは
0.05(50ms)くらいから試すのがおすすめです。 - 文字送りが速いのに音が追いつかないと感じたら
0.03などに下げてみてください。
- まずは
- volume:
- 全体の BGM とのバランスを見て、
0.4 ~ 0.7程度に調整します。
- 全体の BGM とのバランスを見て、
- pitch:
- 標準のままで良ければ
1.0。 - 軽めのポポポ感を出したければ
1.05 ~ 1.2を試してみてください。
- 標準のままで良ければ
- randomizePitch / randomPitchRange:
- 同じ音が続くと耳につく場合は
randomizePitch = trueにします。 randomPitchRange = 0.03 ~ 0.07程度が自然に聞こえやすいです。
- 同じ音が続くと耳につく場合は
- interruptPrevious:
- 通常は
trueのままで問題ありません。 - もし余韻を残したい場合は
falseにして、maxSimultaneousを 2〜3 に設定してみてください。
- 通常は
- maxSimultaneous:
interruptPrevious = trueの場合は 1 のままで OK です(実質使われません)。interruptPrevious = falseのときは、2 ~ 3程度にしておくと音が濁りにくくなります。
- debugLog:
- 最初の動作確認時に有効にすると、コンソールに「鳴った/スキップされた」理由が出るので調整しやすくなります。
- リリース時は false に戻しておくと良いでしょう。
4. 簡易テスト(キー入力で鳴らしてみる)
ダイアログシステムとつなぐ前に、キー入力で playOnType() を呼んで音が鳴るかを確認します。
テスト用に、TypingAudioTester ノードに以下のような簡単なスクリプトを追加します。
- Assets パネルで右クリック → Create → TypeScript を選択し、
TypingAudioTestInput.tsを作成します。 - 以下のコードを貼り付けます(他のカスタムスクリプトには依存しません):
import { _decorator, Component, input, Input, EventKeyboard, KeyCode } from 'cc';
import { TypingAudio } from './TypingAudio';
const { ccclass, property } = _decorator;
@ccclass('TypingAudioTestInput')
export class TypingAudioTestInput extends Component {
@property({
type: TypingAudio,
tooltip: 'テスト対象の TypingAudio コンポーネント'
})
public typingAudio: TypingAudio | null = null;
onLoad() {
if (!this.typingAudio) {
const found = this.getComponent(TypingAudio);
if (found) {
this.typingAudio = found;
} else {
console.warn('[TypingAudioTestInput] TypingAudio が設定されていません。');
}
}
}
start() {
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
onDestroy() {
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
private onKeyDown(event: EventKeyboard) {
if (event.keyCode === KeyCode.SPACE) {
if (this.typingAudio) {
this.typingAudio.playOnType();
}
}
}
}
TypingAudioTesterノードを選択し、Inspector から Add Component → Custom → TypingAudioTestInput を追加します。TypingAudioTestInputの typingAudio プロパティに、同じノードのTypingAudioをドラッグ&ドロップで設定します。- 再生ボタン(▶)でゲームを実行し、ゲームビューがアクティブな状態で スペースキー を押すたびに「ポポポ」音が鳴ることを確認します。
ここまでで TypingAudio 自体の動作確認は完了です。
5. 実際のダイアログシステムへの組み込み例
ダイアログ側の実装はプロジェクトごとに異なりますが、基本的な使い方は以下のようになります。
- ダイアログの 1 文字表示ごとに
typingAudio.playOnType()を呼ぶ。
// ダイアログ表示を行うスクリプトの一部例
import { _decorator, Component } from 'cc';
import { TypingAudio } from './TypingAudio';
const { ccclass, property } = _decorator;
@ccclass('SimpleDialog')
export class SimpleDialog extends Component {
@property(TypingAudio)
public typingAudio: TypingAudio | null = null;
private _fullText: string = 'これはサンプルのダイアログテキストです。';
private _currentIndex: number = 0;
private _interval: number = 0.05;
private _timer: number = 0;
update(deltaTime: number) {
this._timer += deltaTime;
if (this._timer >= this._interval && this._currentIndex < this._fullText.length) {
this._timer = 0;
const ch = this._fullText[this._currentIndex];
this._currentIndex++;
// ここで実際には Label などに文字を追加する処理を書く
// this.label.string += ch;
// 1文字出たタイミングで TypingAudio を呼ぶ
if (this.typingAudio) {
this.typingAudio.playOnType();
}
}
}
}
このように、ダイアログ側は「文字が 1 文字出たときに TypingAudio.playOnType() を呼ぶ」だけで済むようになっています。
まとめ
- TypingAudio は、文字送りに同期した効果音再生に特化した汎用コンポーネントです。
- 外部の GameManager やシングルトンには一切依存せず、このスクリプト単体で完結しています。
typingClip・minInterval・volume・pitch・randomizePitchなどをインスペクタから調整することで、ゲームの雰囲気に合わせた「ポポポ音」を簡単にチューニングできます。- ダイアログシステム側は、文字を 1 文字表示するたびに
playOnType()を呼ぶだけなので、既存のコードに最小限の変更で組み込めます。
このコンポーネントをベースに、例えば「キャラクターごとに違う声色のタイピング音にする」「シーンごとに音量やピッチを変える」といった拡張も、ノードごとに TypingAudio をアタッチして設定を変えるだけで実現できます。
ダイアログ演出のクオリティアップに、ぜひ活用してみてください。




