【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)
    – 他スクリプトから呼び出して使用する公開メソッド。
    AudioSourcetypingClip の存在をチェックし、なければログを出して終了。
    – 渡された文字 ch をもとに、空白・無視対象文字なら早期 return。
    game.totalTime を使って前回再生からの経過時間を測り、minInterval 未満なら再生しない。
    – ピッチをランダム化する場合は _randomRange で値を決定。
    playOneShot() で効果音を再生し、最後の再生時刻を更新。
  • _isWhitespace()
    – 正規表現 /\s/ を使ってスペース・タブ・改行などを判定。
  • _shouldIgnoreChar()
    ignoredChars 文字列に indexOf() で含まれているかをチェック。
  • _randomRange()
    min > max の場合も安全に動くように値を入れ替える、防御的実装。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. フォルダ上で右クリック → CreateTypeScript を選択します。
  3. 新規ファイル名を TypingSound.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、先ほどの 全コード を貼り付けて保存します。

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

ここでは簡単な動作確認として、ボタンを押すたびに 1 文字ずつ Label に文字を追加し、そのたびに音を鳴らす例を想定します。

  1. Hierarchy パネルで右クリック → CreateUICanvas を作成(既にある場合はそのままでOK)。
  2. Canvas を選択し、右クリック → CreateUILabel を作成し、名前を DialogueLabel にします。
  3. DialogueLabel を選択し、Inspector で以下のように設定します。
    • Label の String: 空文字(””)
    • Font Size や Color は任意

3. TypingSound コンポーネントをアタッチ

  1. DialogueLabel ノードを選択します。
  2. Inspector の下部で Add Component をクリックします。
  3. CustomTypingSound を選択して追加します。

この時点で、AudioSource コンポーネントは自動追加されるので、手動で追加する必要はありません(onLoad 内で自動追加されます)。

4. 効果音(AudioClip)の設定

  1. 短いビープ音(WAV / MP3 / OGG など)を用意し、Assets パネルにドラッグ&ドロップしてインポートします。
  2. DialogueLabel ノードを選択し、Inspector の TypingSound コンポーネントを確認します。
  3. Typing Clip プロパティの右側の丸いアイコンをクリックし、先ほどインポートしたビープ音の AudioClip を選択します。
  4. その他のプロパティをお好みで調整します。
    • 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 コンポーネント自体はこのスクリプトに依存しません。

  1. Assets パネルで右クリック → CreateTypeScript を選択し、SimpleTyper.ts を作成します。
  2. 以下のようなシンプルな内容を記述します。

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 を使う場合の設定手順は以下です。

  1. DialogueLabel ノードを選択します。
  2. Add ComponentCustomSimpleTyper を追加します。
  3. SimpleTyperTarget LabelDialogueLabel 自身の Label を設定(もしくは空のままでも、自動取得されます)。
  4. Full Text にテストしたい文章を入力します。
  5. 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 コンポーネントで手軽に解消してみてください。