【Cocos Creator】アタッチするだけ!HearingSensor (聴覚センサー)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】HearingSensor(聴覚センサー)の実装:アタッチするだけで「音シグナルに反応して音源へ調査に向かう」汎用スクリプト

このガイドでは、任意の敵キャラクターや NPC ノードにアタッチするだけで、「プレイヤーが発した音シグナルを検知し、その方向へ向かって調査に向かう」挙動を実現できる HearingSensor コンポーネントを実装します。

外部の GameManager やシングルトンに一切依存せず、インスペクタで設定したパラメータだけで完結するよう設計しているため、どのプロジェクトにも簡単に持ち込んで再利用できます。


コンポーネントの設計方針

1. 想定するゲーム内の流れ

  • プレイヤーなどが「音を立てた」とき、HearingSensor を持つ敵に対して 音シグナル を送る。
  • 音シグナルには最低限「音源のワールド座標」と「音の強さ(距離減衰のしきい値)」を含める。
  • センサーは「自分から見て聞こえる距離かどうか」を判定し、聞こえた場合は「音源の方向へ移動する」調査行動に遷移する。
  • 移動中、一定距離まで近づいたら「到達」とみなし、調査完了として待機状態に戻る。

このコンポーネントは「音シグナルを受け取る窓口 + その方向へ移動する簡易 AI」までを内包します。
音シグナルを送る側(プレイヤー等)は、どんな実装でも構いませんが、本記事では HearingSensor が提供する emitSound() / receiveSound() を直接呼ぶ例を紹介します。

2. 外部依存をなくすための設計

  • ゲーム全体の状態管理やプレイヤーの参照などは一切持たない
  • 「どのノードから音が出たか」は Node | null で受け取るが、必須ではない。
  • 移動は Node のローカル座標を直接変更するシンプルな方式(RigidBody 等に依存しない)。
  • 音の判定は「距離 <= 聴取可能距離」 のみで完結させる。
  • インスペクタで調整可能なプロパティのみで挙動を制御できるようにする。

3. インスペクタで設定可能なプロパティ

HearingSensor では、以下のようなプロパティを用意します。

  • hearingRadius (number)
    • ツールチップ: この距離以内の音シグナルを「聞こえる」と判定します(ワールド座標距離)。
    • 役割: センサーの最大聴取距離。
    • 例: 5 ~ 20 くらいで調整。
  • minSoundStrength (number)
    • ツールチップ: この強さ未満の音は無視します。音源側が発する音の強さと比較します。
    • 役割: 音の「ボリューム」のしきい値。小さすぎる音は無視したい場合に使用。
    • 例: 0.1 ~ 1.0。
  • moveSpeed (number)
    • ツールチップ: 音源へ向かうときの移動速度(単位/秒)。
    • 役割: 音源に向かうときの移動速度。
    • 例: 1 ~ 10。
  • arrivalThreshold (number)
    • ツールチップ: 音源の位置にこれ以下の距離まで近づいたら「到達」とみなして調査完了にします。
    • 役割: どこまで近づけば調査完了扱いにするか。
    • 例: 0.2 ~ 1.0。
  • forgetTime (number)
    • ツールチップ: 音を聞いてからこの秒数が経過すると、音源を忘れて待機状態に戻ります。
    • 役割: 調査に向かっている途中で時間切れになったら、音源を忘れて待機に戻す。
    • 例: 3 ~ 10 秒。
  • debugDrawGizmos (boolean)
    • ツールチップ: 有効にすると、エディタ上で聴取範囲(円)をギズモとして表示します。
    • 役割: 聴取範囲の可視化。デバッグ用。
  • debugLog (boolean)
    • ツールチップ: 有効にすると、音を検知・無視・到達した際にログを出力します。
    • 役割: チューニング時に挙動を確認しやすくするためのログ出力スイッチ。

4. センサーの内部状態

コンポーネント内部では、以下のような状態管理を行います。

  • Idle: 音を待っている状態。
  • Investigating: 直近で聞いた音源の方向へ移動している状態。

保持する内部データ:

  • lastHeardPosition: Vec3(最後に聞いた音源のワールド座標)
  • lastHeardTime: number(最後に音を聞いた時間)
  • lastSoundStrength: number(最後に聞いた音の強さ)
  • state: enum(Idle / Investigating)

これらはすべてコンポーネント内に閉じた情報であり、外部スクリプトに依存しません。


TypeScriptコードの実装


import { _decorator, Component, Node, Vec3, math, director, Color, Gizmo } from 'cc';
const { ccclass, property } = _decorator;

/**
 * HearingSensor
 * 
 * 任意のノードにアタッチして使用する「聴覚センサー」コンポーネント。
 * 
 * - receiveSound() / emitSound() を呼び出すことで音シグナルを受信
 * - 聞こえる距離・強さであれば、その音源の位置へ向かって移動
 * - 一定距離まで近づくと調査完了(待機状態に戻る)
 * - 一定時間が経過すると音源を忘れて待機状態に戻る
 */
enum HearingState {
    Idle = 0,
    Investigating = 1,
}

@ccclass('HearingSensor')
export class HearingSensor extends Component {

    @property({
        tooltip: 'この距離以内の音シグナルを「聞こえる」と判定します(ワールド座標距離)。',
        min: 0.1,
    })
    public hearingRadius: number = 8;

    @property({
        tooltip: 'この強さ未満の音は無視します。音源側が発する音の強さと比較します。',
        min: 0,
    })
    public minSoundStrength: number = 0.1;

    @property({
        tooltip: '音源へ向かうときの移動速度(単位/秒)。',
        min: 0,
    })
    public moveSpeed: number = 3;

    @property({
        tooltip: '音源の位置にこれ以下の距離まで近づいたら「到達」とみなして調査完了にします。',
        min: 0.01,
    })
    public arrivalThreshold: number = 0.5;

    @property({
        tooltip: '音を聞いてからこの秒数が経過すると、音源を忘れて待機状態に戻ります。',
        min: 0,
    })
    public forgetTime: number = 5;

    @property({
        tooltip: '有効にすると、エディタ上で聴取範囲(円)をギズモとして表示します。',
    })
    public debugDrawGizmos: boolean = true;

    @property({
        tooltip: '有効にすると、音を検知・無視・到達した際にログを出力します。',
    })
    public debugLog: boolean = false;

    // 内部状態
    private _state: HearingState = HearingState.Idle;
    private _lastHeardPosition: Vec3 | null = null;
    private _lastHeardTime: number = 0;
    private _lastSoundStrength: number = 0;

    // 作業用ベクトル(GC削減)
    private _tempWorldPos: Vec3 = new Vec3();
    private _tempTargetDir: Vec3 = new Vec3();

    onLoad() {
        // 特定の必須コンポーネントはないが、Node の存在は保証されている
        if (!this.node) {
            console.error('[HearingSensor] node が存在しません。ノードにアタッチされていることを確認してください。');
        }
    }

    start() {
        this._resetState();
    }

    update(deltaTime: number) {
        if (this._state === HearingState.Investigating) {
            this._updateInvestigating(deltaTime);
        }
    }

    /**
     * 外部から「音シグナル」を送るためのヘルパー。
     * 
     * @param sourceNode 音を発したノード(null でも可)
     * @param soundStrength 音の強さ(0 以上)。大きいほど遠くまで届く想定。
     */
    public emitSound(sourceNode: Node | null, soundStrength: number = 1): void {
        if (!sourceNode) {
            console.warn('[HearingSensor] emitSound が null ノードで呼ばれました。音源の位置が不明なため無視します。');
            return;
        }
        const worldPos = sourceNode.worldPosition.clone();
        this.receiveSound(worldPos, soundStrength, sourceNode);
    }

    /**
     * 外部から直接ワールド座標で「音シグナル」を送る場合はこちらを使用。
     * 
     * @param worldPosition 音源のワールド座標
     * @param soundStrength 音の強さ(0 以上)
     * @param sourceNode 音を発したノード(任意)
     */
    public receiveSound(worldPosition: Vec3, soundStrength: number = 1, sourceNode?: Node | null): void {
        if (!this.enabledInHierarchy) {
            return;
        }

        if (soundStrength < this.minSoundStrength) {
            if (this.debugLog) {
                console.log('[HearingSensor] 音の強さがしきい値未満のため無視しました。strength=', soundStrength);
            }
            return;
        }

        // 自身のワールド位置を取得
        this.node.getWorldPosition(this._tempWorldPos);

        const distance = Vec3.distance(this._tempWorldPos, worldPosition);

        if (distance > this.hearingRadius) {
            if (this.debugLog) {
                console.log('[HearingSensor] 聴取範囲外のため音を無視しました。distance=', distance.toFixed(2));
            }
            return;
        }

        // ここまで来たら「聞こえた」と判定
        this._lastHeardPosition = worldPosition.clone();
        this._lastHeardTime = director.getTotalTime() / 1000; // ms → s
        this._lastSoundStrength = soundStrength;
        this._state = HearingState.Investigating;

        if (this.debugLog) {
            const from = this._tempWorldPos;
            console.log('[HearingSensor] 音を検知しました。',
                'from=(', from.x.toFixed(2), from.y.toFixed(2), from.z.toFixed(2), ')',
                'to=(', worldPosition.x.toFixed(2), worldPosition.y.toFixed(2), worldPosition.z.toFixed(2), ')',
                'distance=', distance.toFixed(2),
                'strength=', soundStrength.toFixed(2),
                'sourceNode=', sourceNode ? sourceNode.name : 'none'
            );
        }
    }

    /**
     * 調査中の更新処理
     */
    private _updateInvestigating(deltaTime: number): void {
        if (!this._lastHeardPosition) {
            this._resetState();
            return;
        }

        const now = director.getTotalTime() / 1000;
        const elapsed = now - this._lastHeardTime;

        // 一定時間経過で忘れる
        if (this.forgetTime > 0 && elapsed >= this.forgetTime) {
            if (this.debugLog) {
                console.log('[HearingSensor] 音源を忘れました。経過時間=', elapsed.toFixed(2));
            }
            this._resetState();
            return;
        }

        // 現在位置
        this.node.getWorldPosition(this._tempWorldPos);

        // 目標方向ベクトル
        Vec3.subtract(this._tempTargetDir, this._lastHeardPosition, this._tempWorldPos);
        const distance = this._tempTargetDir.length();

        // すでに十分近いなら到達とみなす
        if (distance <= this.arrivalThreshold) {
            if (this.debugLog) {
                console.log('[HearingSensor] 音源に到達しました。distance=', distance.toFixed(3));
            }
            this._resetState();
            return;
        }

        if (distance <= 0.0001) {
            // 同一点扱い
            this._resetState();
            return;
        }

        // 正規化してスピードを掛ける
        this._tempTargetDir.normalize();
        const moveDistance = this.moveSpeed * deltaTime;

        // 到達しすぎないように clamp
        const actualMove = Math.min(moveDistance, distance);
        this._tempTargetDir.multiplyScalar(actualMove);

        // 新しいワールド位置
        const newWorldPos = this._tempWorldPos.add(this._tempTargetDir);

        // ワールド座標をローカル座標に変換して設定
        this.node.parent?.inverseTransformPoint(this._tempWorldPos, newWorldPos);
        this.node.setPosition(this._tempWorldPos);
    }

    /**
     * 状態をアイドルに戻す
     */
    private _resetState(): void {
        this._state = HearingState.Idle;
        this._lastHeardPosition = null;
        this._lastSoundStrength = 0;
        // _lastHeardTime は次回のために残しておいても害はない
    }

    /**
     * エディタ上のギズモ描画(聴取範囲を円で表示)
     * 
     * Cocos Creator 3.8 では、カスタムギズモはエディタ拡張で描画するのが正式ですが、
     * ここでは簡易的に onDrawGizmos を使用できる環境を想定しています。
     * 使用環境によっては無視される場合があります。
     */
    // @ts-ignore: Editor-only
    onDrawGizmos(gizmo: Gizmo) {
        if (!this.debugDrawGizmos) {
            return;
        }
        // 聴取範囲の円を描画(XY 平面想定)
        const color = new Color(0, 255, 255, 128); // シアン
        gizmo.color(color);
        gizmo.drawCircle(this.node.worldPosition, this.hearingRadius);
    }
}

コードの主要部分の解説

  • onLoad
    • このコンポーネントには必須の標準コンポーネントはありませんが、防御的に node の存在を確認し、問題があればエラーログを出すようにしています。
  • start
    • 初期状態を Idle にリセットします。
  • update(deltaTime)
    • 状態が Investigating のときのみ、_updateInvestigating() を呼び出して音源へ向かう移動処理を行います。
  • emitSound()
    • 音を発したノードから簡単に呼べるようにしたヘルパーです。
    • sourceNode.worldPosition を取得して receiveSound() に渡します。
    • ノードが null の場合は警告を出して無視します。
  • receiveSound(worldPosition, soundStrength, sourceNode?)
    • 音シグナルのメイン入口です。
    • minSoundStrength より小さい音は無視します。
    • 自身のワールド座標との距離を計算し、hearingRadius より遠い場合は無視します。
    • 聞こえた場合は _lastHeardPosition, _lastHeardTime, _lastSoundStrength を更新し、状態を Investigating に変更します。
    • debugLog が有効なら、音の検知・無視などを console.log に出力します。
  • _updateInvestigating(deltaTime)
    • forgetTime を超えたら音源を忘れて Idle に戻ります。
    • 現在位置と音源位置の距離が arrivalThreshold 以下になったら到達とみなして Idle に戻ります。
    • 移動は「現在位置から音源方向へ moveSpeed * deltaTime だけ進む」方式です。
    • ワールド座標で計算した後、親ノードのローカル座標系に変換して node.setPosition() しています。
  • onDrawGizmos(gizmo)
    • エディタ上で聴取範囲を円として可視化するための処理です。
    • 環境によっては無視される可能性がありますが、3.8 系のエディタ拡張と組み合わせれば視覚的デバッグに役立ちます。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を HearingSensor.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の HearingSensor のコードを丸ごと貼り付けて保存します。

2. テスト用ノード(敵キャラクター)の作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択し、テスト用ノードを作成します。
    • 名前例: EnemyWithHearing
  2. このノードが「音を聞いて動く対象」となります。

3. HearingSensor コンポーネントのアタッチ

  1. Hierarchy で EnemyWithHearing ノードを選択します。
  2. Inspector パネル下部の Add Component ボタンをクリックします。
  3. Custom カテゴリの中から HearingSensor を選択して追加します。

4. プロパティの設定例

Inspector で HearingSensor の各プロパティを以下のように設定してみます。

  • Hearing Radius: 10
  • Min Sound Strength: 0.1
  • Move Speed: 3
  • Arrival Threshold: 0.5
  • Forget Time: 5
  • Debug Draw Gizmos: ON
  • Debug Log: ON

これで、EnemyWithHearing半径 10 ユニット以内で強さ 0.1 以上の音 を聞き取り、秒速 3 ユニットで音源へ向かって移動し、0.5 ユニット以内まで近づいたら調査完了として止まるようになります。
音を聞いてから 5 秒 経過すると、たとえ到達していなくても音源を忘れて待機状態に戻ります。

5. 音シグナルを送るテスト(簡易版)

ここでは、エディタから直接呼び出すのではなく、シンプルなテストスクリプトを使って音シグナルを送る方法を紹介します。
注意: これは HearingSensor 自身には依存しない、あくまでテスト用の例です。実プロジェクトではプレイヤーの攻撃や移動スクリプトから呼び出してください。

  1. Assets パネルで右クリック → Create → TypeScript を選択し、SoundEmitterTest.ts を作成します。
  2. 以下のような簡単なテストコードを貼り付けます(任意):

import { _decorator, Component, Node, Vec3, input, Input, EventKeyboard, KeyCode } from 'cc';
import { HearingSensor } from './HearingSensor';
const { ccclass, property } = _decorator;

@ccclass('SoundEmitterTest')
export class SoundEmitterTest extends Component {

    @property({ tooltip: '音を聞かせたい対象(HearingSensor を持つノード)' })
    public targetWithSensor: Node | null = null;

    @property({ tooltip: '発生させる音の強さ(大きいほど遠くまで届く想定)', min: 0 })
    public soundStrength: number = 1;

    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.targetWithSensor) {
                console.warn('[SoundEmitterTest] targetWithSensor が設定されていません。');
                return;
            }
            const sensor = this.targetWithSensor.getComponent(HearingSensor);
            if (!sensor) {
                console.warn('[SoundEmitterTest] targetWithSensor に HearingSensor がアタッチされていません。');
                return;
            }

            // このノードの位置から音を出したことにする
            const worldPos = this.node.worldPosition.clone();
            sensor.receiveSound(worldPos, this.soundStrength, this.node);
        }
    }
}

このテストスクリプトの使い方:

  1. Hierarchy で新しくノードを作成(例: PlayerDummy)。
  2. PlayerDummySoundEmitterTest コンポーネントを追加します。
  3. SoundEmitterTestTarget With Sensor に、先ほど作成した EnemyWithHearing ノードをドラッグ&ドロップします。
  4. Sound Strength1 以上に設定します。
  5. ゲームを再生し、ゲームビューがアクティブな状態で スペースキー を押すと、PlayerDummy の位置から音が発生します。
  6. EnemyWithHearing が聴取範囲内にいれば、その方向へ移動し始めるはずです。

このテスト用スクリプトは HearingSensor に依存しますが、HearingSensor 自体は何の外部スクリプトにも依存していないことに注意してください。
実際のゲームでは、プレイヤーのジャンプ・着地・攻撃などのイベント発生時に HearingSensor を持つ敵へ receiveSound() を呼び出すようにすれば、そのまま利用できます。

6. よくある調整ポイント

  • 敵が近づきすぎてしまうarrivalThreshold を少し大きくする(例: 1.0)。
  • 敵がなかなか音に反応しないhearingRadius を大きくする、または音源側の soundStrength を大きくする。
  • 敵が延々と追い続けてしまうforgetTime を短くする(例: 2 ~ 3 秒)。
  • 挙動を確認したいdebugLogdebugDrawGizmos を ON にする。

まとめ

この HearingSensor コンポーネントは、

  • 外部の GameManager やシングルトンに一切依存せず、
  • インスペクタのパラメータだけで「音を聞いて調査に向かう」挙動を完結させ、
  • 任意のノードにアタッチするだけで再利用できる

というコンセプトで設計されています。

応用例としては:

  • ステルスゲームで「プレイヤーが走ったり物を落としたときだけ敵が振り向いて調査に来る」挙動。
  • ホラーゲームで「物音を立てるとどこからともなく敵が近づいてくる」演出。
  • パズルゲームで「音を使って敵を誘導するギミック」。

など、多くのシーンに活用できます。

本コンポーネントをベースに、

  • 音の種類(足音・銃声など)によって反応を変える。
  • 視覚センサー(視野角・視界遮蔽など)と組み合わせる。
  • 到達後に一定時間うろうろする・元のパトロールルートに戻る。

といった拡張も容易です。
まずはこのシンプルな HearingSensor をプロジェクトに組み込み、プレイヤーの「音」をゲームデザインに活かしてみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!