【Cocos Creator 3.8】TypingSound の実装:アタッチするだけで「文字が出るたびにビープ音が鳴る」会話ウィンドウを実現する汎用スクリプト
会話シーンやノベルゲームで、テキストが一文字ずつ表示されるときに「ピッ、ピッ」と短いビープ音が鳴ると、読み心地や演出が一気にリッチになります。この記事では、会話ウィンドウのノードにアタッチするだけで、文字送りに合わせて効果音を鳴らせる汎用コンポーネント「TypingSound」を実装します。
外部の GameManager やシングルトンには一切依存せず、このスクリプト単体で完結する設計です。インスペクタから音量や再生間隔、特定文字で音をスキップする設定などを柔軟に変更できます。
コンポーネントの設計方針
想定する利用シーンと動作イメージ
- 会話ウィンドウのノード(Label や RichText を持つノード)に
TypingSoundをアタッチする。 - 「文字送り処理(タイピングアニメーション)」側が、1 文字表示するタイミングでこのコンポーネントのメソッドを呼ぶ。
- TypingSound は、呼ばれるたびに「ビープ音」を再生するが、一定間隔以下では鳴らさない・特定の文字は音を鳴らさないなどの制御を行う。
- 会話ウィンドウごとに違う音・音量・ピッチを設定できる。
注意点として、このコンポーネントは「文字送りの制御」は行いません。文字送りは既存のシナリオシステムや自作のスクリプトで行い、TypingSound は「1 文字出たよ」という通知を受けて音を鳴らす役割に専念します。
外部依存をなくすために、以下の方針で設計します。
- 再生に必要な
AudioSourceは、同じノード上に配置されていることを前提とし、なければonLoadで自動追加します。 - 効果音(
AudioClip)はインスペクタから直接指定。 - 文字送り通知は public メソッド(
playForChar())として公開し、他スクリプトから呼び出せるようにします(イベントやシングルトンは使わない)。
インスペクタで設定可能なプロパティ
以下のプロパティを持たせます。
- typingClip: AudioClip | null
– 文字送り時に鳴らすビープ音。
– 短くカットした効果音を推奨。
– 未設定の場合はエラーログを出し、再生をスキップ。 - volume: number(0〜1、デフォルト 0.6)
– 再生音量。
–AudioSource.volumeに適用される。 - pitch: number(0.5〜2.0、デフォルト 1.0)
– 再生ピッチ。
– キャラクターごとに少しピッチを変えると個性が出る。 - minInterval: number(秒、デフォルト 0.03)
– 連続して文字が出るとき、この秒数より短い間隔では音を鳴らさない。
– 例: 0.03 の場合、1 秒間に最大約 33 回まで。 - ignoreWhitespace: boolean(デフォルト true)
– 空白文字(スペース、タブ、改行など)のときは音を鳴らさない。 - ignoredChars: string(デフォルト “。、!?,.!? “)
– この文字列に含まれる文字が出たときは音を鳴らさない。
– 句読点や記号などを指定しておくと耳障りになりにくい。 - randomizePitch: boolean(デフォルト false)
– true の場合、再生ごとにピッチを少しランダムに揺らす。 - pitchRandomRange: number(デフォルト 0.05)
–randomizePitchが true のとき、
basePitch ± pitchRandomRangeの範囲でランダム化。
– 例: basePitch=1.0, pitchRandomRange=0.05 → 0.95〜1.05 の範囲。
また、内部状態として以下を持ちます。
- _audioSource: AudioSource | null — 同一ノードの AudioSource 参照。
- _lastPlayTime: number — 最後に音を鳴らした時間(
game.totalTime)。
TypeScriptコードの実装
以下が完成した TypingSound.ts の全コードです。
import { _decorator, Component, AudioSource, AudioClip, game } from 'cc';
const { ccclass, property } = _decorator;
/**
* TypingSound
* 文字送り時に短いビープ音を鳴らす汎用コンポーネント。
*
* 他のスクリプトから playForChar() を呼び出して使用します。
* 例:
* const typingSound = this.node.getComponent(TypingSound);
* typingSound?.playForChar(nextChar);
*/
@ccclass('TypingSound')
export class TypingSound extends Component {
@property({
type: AudioClip,
tooltip: '文字送り時に再生する効果音(短いビープ音を推奨)',
})
public typingClip: AudioClip | null = null;
@property({
tooltip: '再生音量(0.0〜1.0)',
min: 0.0,
max: 1.0,
slide: true,
})
public volume: number = 0.6;
@property({
tooltip: '基準ピッチ(0.5〜2.0)',
min: 0.5,
max: 2.0,
slide: true,
})
public pitch: number = 1.0;
@property({
tooltip: '文字ごとの再生間隔の最小値(秒)。これより短い間隔では音を鳴らさない。',
min: 0.0,
max: 0.5,
slide: true,
})
public minInterval: number = 0.03;
@property({
tooltip: '空白文字(スペース・改行・タブなど)のときは音を鳴らさない',
})
public ignoreWhitespace: boolean = true;
@property({
tooltip: '音を鳴らさない文字の一覧。ここに含まれる文字は再生時にスキップされる',
})
public ignoredChars: string = '。、!?,.!? ';
@property({
tooltip: '再生ごとにピッチを少しランダムに揺らす',
})
public randomizePitch: boolean = false;
@property({
tooltip: 'ピッチのランダム幅。pitch ± この値 の範囲でランダム化される',
min: 0.0,
max: 0.5,
slide: true,
})
public pitchRandomRange: number = 0.05;
/** 内部で使用する AudioSource 参照 */
private _audioSource: AudioSource | null = null;
/** 最後に音を鳴らした時間(ミリ秒) */
private _lastPlayTime: number = 0;
onLoad() {
// AudioSource を取得 or 自動追加
this._audioSource = this.getComponent(AudioSource);
if (!this._audioSource) {
this._audioSource = this.node.addComponent(AudioSource);
console.log('[TypingSound] AudioSource が見つからなかったため、自動で追加しました。');
}
if (!this.typingClip) {
console.warn('[TypingSound] typingClip が設定されていません。インスペクタで AudioClip を設定してください。');
}
}
start() {
// AudioSource の基本設定を反映
if (this._audioSource) {
this._audioSource.clip = this.typingClip;
this._audioSource.volume = this.volume;
this._audioSource.pitch = this.pitch;
this._audioSource.loop = false;
}
this._lastPlayTime = game.totalTime;
}
/**
* 1 文字分の表示に対応して音を鳴らす。
* 文字送り処理からこのメソッドを呼び出してください。
*
* @param ch 表示された文字。null や空文字の場合は文字種判定をスキップして interval のみで判定。
*/
public playForChar(ch?: string | null): void {
if (!this._audioSource) {
console.error('[TypingSound] AudioSource が見つかりません。このノードに AudioSource を追加してください。');
return;
}
if (!this.typingClip) {
console.warn('[TypingSound] typingClip が未設定のため、音を再生できません。');
return;
}
// 文字種によるスキップ判定
if (ch != null && ch !== '') {
if (this.ignoreWhitespace && this._isWhitespace(ch)) {
return;
}
if (this._shouldIgnoreChar(ch)) {
return;
}
}
// 再生間隔チェック
const now = game.totalTime; // ミリ秒
const elapsedSec = (now - this._lastPlayTime) / 1000.0;
if (elapsedSec < this.minInterval) {
return;
}
// AudioSource 設定を更新
this._audioSource.clip = this.typingClip;
this._audioSource.volume = this.volume;
// ピッチ決定
let playPitch = this.pitch;
if (this.randomizePitch && this.pitchRandomRange > 0) {
const minPitch = this.pitch - this.pitchRandomRange;
const maxPitch = this.pitch + this.pitchRandomRange;
playPitch = this._randomRange(minPitch, maxPitch);
}
this._audioSource.pitch = playPitch;
// 再生
this._audioSource.playOneShot(this.typingClip, this.volume);
this._lastPlayTime = now;
}
/**
* 空白文字かどうかを判定
*/
private _isWhitespace(ch: string): boolean {
if (!ch) {
return false;
}
// スペース・タブ・改行など
return /\s/.test(ch);
}
/**
* ignoredChars に含まれているかどうかを判定
*/
private _shouldIgnoreChar(ch: string): boolean {
if (!ch || !this.ignoredChars) {
return false;
}
// 1 文字ずつ比較(サロゲートペア対策として ch[0] ではなく ch をそのまま使用)
return this.ignoredChars.indexOf(ch) !== -1;
}
/**
* min〜max の範囲でランダムな数値を返す
*/
private _randomRange(min: number, max: number): number {
if (min > max) {
const tmp = min;
min = max;
max = tmp;
}
return min + Math.random() * (max - min);
}
}
コードのポイント解説
- onLoad()
– 同じノード上にAudioSourceがあるかをgetComponent(AudioSource)でチェック。
– 見つからなければthis.node.addComponent(AudioSource)で自動追加し、ログを出します。
–typingClipが未設定の場合はconsole.warnで警告。 - start()
– AudioSource に初期設定(クリップ、音量、ピッチ、ループオフ)を反映。
–_lastPlayTimeを現在時刻で初期化。 - playForChar(ch?: string)
– 他スクリプトから呼び出して使用する公開メソッド。
–AudioSourceとtypingClipの存在をチェックし、なければログを出して終了。
– 渡された文字chをもとに、空白・無視対象文字なら早期 return。
–game.totalTimeを使って前回再生からの経過時間を測り、minInterval未満なら再生しない。
– ピッチをランダム化する場合は_randomRangeで値を決定。
–playOneShot()で効果音を再生し、最後の再生時刻を更新。 - _isWhitespace()
– 正規表現/\s/を使ってスペース・タブ・改行などを判定。 - _shouldIgnoreChar()
–ignoredChars文字列にindexOf()で含まれているかをチェック。 - _randomRange()
–min > maxの場合も安全に動くように値を入れ替える、防御的実装。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - フォルダ上で右クリック → Create → TypeScript を選択します。
- 新規ファイル名を
TypingSound.tsに変更します。 - ダブルクリックしてエディタ(VS Code など)で開き、先ほどの 全コード を貼り付けて保存します。
2. テスト用のノードを作成
ここでは簡単な動作確認として、ボタンを押すたびに 1 文字ずつ Label に文字を追加し、そのたびに音を鳴らす例を想定します。
- Hierarchy パネルで右クリック → Create → UI → Canvas を作成(既にある場合はそのままでOK)。
- Canvas を選択し、右クリック → Create → UI → Label を作成し、名前を
DialogueLabelにします。 DialogueLabelを選択し、Inspector で以下のように設定します。- Label の String: 空文字(””)
- Font Size や Color は任意
3. TypingSound コンポーネントをアタッチ
DialogueLabelノードを選択します。- Inspector の下部で Add Component をクリックします。
- Custom → TypingSound を選択して追加します。
この時点で、AudioSource コンポーネントは自動追加されるので、手動で追加する必要はありません(onLoad 内で自動追加されます)。
4. 効果音(AudioClip)の設定
- 短いビープ音(WAV / MP3 / OGG など)を用意し、Assets パネルにドラッグ&ドロップしてインポートします。
DialogueLabelノードを選択し、Inspector の TypingSound コンポーネントを確認します。- Typing Clip プロパティの右側の丸いアイコンをクリックし、先ほどインポートしたビープ音の
AudioClipを選択します。 - その他のプロパティをお好みで調整します。
- Volume: 0.4〜0.7 くらいがおすすめ。
- Pitch: 1.0(標準)。キャラごとに 0.9, 1.1 など変えると良い。
- Min Interval: 0.02〜0.05。速いタイピングなら 0.02、ゆっくりなら 0.05 など。
- Ignore Whitespace: ON のままで OK。
- Ignored Chars: デフォルトの「。、!?,.!? 」のままで OK。
- Randomize Pitch: 少し揺らしたい場合は ON。
- Pitch Random Range: 0.03〜0.07 程度が自然。
5. 文字送り処理からの呼び出し例
TypingSound 自体は「音を鳴らすだけ」のコンポーネントなので、文字送り処理は別スクリプトで行います。ここでは、テスト用に簡単なスクリプト例を示します。
※このスクリプトはあくまで「使い方の例」であり、TypingSound コンポーネント自体はこのスクリプトに依存しません。
- Assets パネルで右クリック → Create → TypeScript を選択し、
SimpleTyper.tsを作成します。 - 以下のようなシンプルな内容を記述します。
import { _decorator, Component, Label } from 'cc';
import { TypingSound } from './TypingSound';
const { ccclass, property } = _decorator;
@ccclass('SimpleTyper')
export class SimpleTyper extends Component {
@property(Label)
public targetLabel: Label | null = null;
@property({
tooltip: 'テスト用の全文テキスト',
})
public fullText: string = 'これはテストです。TypingSound コンポーネントの動作確認をしています。';
@property({
tooltip: '1 文字あたりの表示間隔(秒)',
min: 0.01,
max: 0.3,
slide: true,
})
public charInterval: number = 0.05;
private _currentIndex: number = 0;
private _elapsed: number = 0;
private _typingSound: TypingSound | null = null;
start() {
if (!this.targetLabel) {
this.targetLabel = this.getComponent(Label);
}
if (!this.targetLabel) {
console.error('[SimpleTyper] Label が見つかりません。targetLabel を設定するか、このノードに Label を追加してください。');
return;
}
this.targetLabel.string = '';
this._typingSound = this.getComponent(TypingSound);
if (!this._typingSound) {
console.warn('[SimpleTyper] TypingSound が見つかりません。音なしで文字送りを行います。');
}
}
update(deltaTime: number) {
if (!this.targetLabel) {
return;
}
if (this._currentIndex >= this.fullText.length) {
return; // 全文表示済み
}
this._elapsed += deltaTime;
if (this._elapsed >= this.charInterval) {
this._elapsed = 0;
const ch = this.fullText.charAt(this._currentIndex);
this._currentIndex++;
this.targetLabel.string += ch;
// 文字送りにあわせて TypingSound を呼び出す
this._typingSound?.playForChar(ch);
}
}
}
この SimpleTyper を使う場合の設定手順は以下です。
DialogueLabelノードを選択します。- Add Component → Custom → SimpleTyper を追加します。
SimpleTyperの Target Label にDialogueLabel自身の Label を設定(もしくは空のままでも、自動取得されます)。- Full Text にテストしたい文章を入力します。
- Char Interval を 0.03〜0.08 くらいに設定します。
ゲームを再生すると、Label に 1 文字ずつテキストが表示され、それに合わせて TypingSound のビープ音が鳴るはずです。
6. よくあるつまずきポイント
- 音が鳴らない場合
- Inspector の TypingSound.typingClip に AudioClip が設定されているか確認。
- エディタの Console に
[TypingSound]の警告やエラーが出ていないか確認。 - 再生環境(Web / ネイティブ)で音量がミュートになっていないか確認。
- 音が鳴りすぎてうるさい場合
- Min Interval を大きめ(0.05〜0.1)にする。
- Volume を下げる(0.3〜0.5 など)。
- Ignored Chars に記号や記述が多い文字を追加する。
まとめ
今回実装した TypingSound コンポーネントは、
- 単体スクリプトで完結し、他のカスタムスクリプトに依存しない。
- ノードにアタッチするだけで 文字送りのたびにビープ音を鳴らせる。
- 音量・ピッチ・再生間隔・無視する文字・ピッチのランダム化などを インスペクタから柔軟に調整できる。
- 会話ウィンドウごと・キャラクターごとに違う設定を簡単に用意できる。
文字送りロジック側は、playForChar() を 1 行呼ぶだけでよいため、既存のシナリオシステムや UI にも簡単に組み込めます。TypingSound 自体は会話システムに依存していないので、どのプロジェクトにもそのまま持ち込める汎用コンポーネントとして再利用が可能です。
応用として、
- キャラクターごとに別の TypingSound を用意し、声色の違いを演出する。
- 重要なセリフだけ Min Interval を長めにして、ゆっくり読み上げるような印象にする。
- システムメッセージ用に、より硬い・電子音っぽいサウンドを使う。
といった工夫も簡単に行えます。
会話シーンの「無音の寂しさ」を、ぜひこの TypingSound コンポーネントで手軽に解消してみてください。
