【Cocos Creator】アタッチするだけ!VisionCone (視界コーン)の実装方法【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】VisionCone の実装:アタッチするだけで「前方視界コーン+壁による遮蔽判定」を実現する汎用スクリプト

このガイドでは、敵キャラなどの「視界」を表現するための汎用コンポーネント VisionCone を実装します。
任意のノードにアタッチするだけで、そのノードの前方に扇形の視界エリア(視界コーン)を持たせ、壁に遮られていない場合のみ「対象を発見した」と判定できるようにします。

敵 AI だけでなく、監視カメラ・トラップ・センサーなどにも流用できるよう、完全に独立した汎用コンポーネントとして設計します。


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

機能要件の整理

  • このコンポーネントをアタッチしたノードの「前方」に扇形の視界を持つ。
  • 視界のパラメータ(半径・角度)はインスペクタから調整可能。
  • 「検知対象」となるノード群をインスペクタから登録し、そのノードが視界内かつ壁に遮られていない場合のみ「発見」と判定する。
  • 「壁」として扱うコライダー(Collider2D)をレイヤーまたはタグで判定できるようにし、視線と壁の間に障害物があるかを Raycast2D でチェックする。
  • 検知状態(見えている/見えていない)を、以下のような形で外部から参照できるようにする:
    • 現在「見えている対象ノードの配列」を公開プロパティで参照可能にする。
    • 検知/ロストのタイミングでログ出力(デバッグ用)。
  • 視界の「可視化」として、任意で Graphics コンポーネントで扇形を描画できるオプションを用意する。
  • 他のカスタムスクリプトには一切依存しない。必要な情報はすべて @property で設定。

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

以下のようなプロパティを持たせます。

  • enabledDebugDraw: boolean
    ・視界コーンを Graphics で描画するかどうか。
    true の場合、ノードに Graphics コンポーネントが必要(なければ自動追加)。
  • radius: number
    ・視界の最大距離(ワールド座標上の長さ、単位は Cocos の単位)。
    ・例:300 なら、ノードの位置から 300 の距離までが視界の届く範囲。
  • fovAngle: number
    ・視界の角度(度数)。
    ・例:90 なら、前方を中心に左右 45 度ずつの円錐視界。
  • targetNodes: Node[]
    ・視界判定の対象となるノード群を配列で指定。
    ・敵がプレイヤーを探す場合は、プレイヤーノードをここに登録。
  • usePhysicsBlocking: boolean
    物理レイキャストによる遮蔽判定を行うかどうか。
    true の場合、2D 物理システム(PhysicsSystem2D)と Collider2D が必要。
  • blockingLayerMask: number
    ・レイキャストが「壁」とみなすレイヤーマスク(2D 物理レイヤーマスク)。
    usePhysicsBlockingtrue のときだけ使用。
    ・例:壁用の PhysicsGroup を 2 番にしている場合、1 << 2 を指定。
  • updateInterval: number
    ・視界判定を行う間隔(秒)。
    ・0 以下の場合は毎フレーム update で判定。
    ・負荷軽減のため、0.10.2 程度を推奨。
  • minVisibleTime: number
    ・対象が「発見状態」とみなされるまでに必要な連続可視時間(秒)。
    ・一瞬だけ見えた場合に「発見」としないためのディレイ。
  • minLostTime: number
    ・対象が「ロスト状態」とみなされるまでに必要な連続不可視時間(秒)。
    ・視界から一瞬外れてもすぐにはロスト扱いにしない。

内部実装の概要

  1. ノードの「前方」を Node.forward から取得し、ワールド座標系の前方ベクトルを求める。
  2. targetNode に対して:
    • ノードのワールド座標を取得。
    • 距離が radius 以下か確認。
    • 前方ベクトルとの角度が fovAngle / 2 以内か確認。
    • usePhysicsBlocking === true の場合、PhysicsSystem2D.instance.raycast を使って、視線と壁の間に遮蔽物があるかをチェック。
    • 可視/不可視の継続時間をカウントし、minVisibleTime / minLostTime を超えたらステートを切り替える。
  3. 結果として、「現在見えている対象ノードの配列」を公開プロパティで保持。
  4. enabledDebugDrawtrue のときは、Graphics で視界コーンを描画。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    Vec3,
    Vec2,
    math,
    Graphics,
    Color,
    PhysicsSystem2D,
    ERaycast2DType,
    director,
} from 'cc';

const { ccclass, property } = _decorator;

interface TargetState {
    node: Node;
    isVisible: boolean;
    visibleTime: number;
    invisibleTime: number;
}

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

    @property({
        tooltip: '視界コーンを Graphics で描画するかどうか。\ntrue の場合、このノードに Graphics コンポーネントが必要(自動追加されます)。',
    })
    public enabledDebugDraw: boolean = true;

    @property({
        tooltip: '視界の最大距離(ワールド座標単位)。',
        min: 0,
    })
    public radius: number = 300;

    @property({
        tooltip: '視界の角度(度数)。\n例: 90 なら前方左右 45 度の視界。',
        min: 0,
        max: 360,
    })
    public fovAngle: number = 90;

    @property({
        type: [Node],
        tooltip: '視界判定の対象となるノード群。\nプレイヤーなど検知したい対象をここに登録します。',
    })
    public targetNodes: Node[] = [];

    @property({
        tooltip: '物理レイキャストによる壁遮蔽判定を行うかどうか。\ntrue の場合、PhysicsSystem2D と Collider2D が必要です。',
    })
    public usePhysicsBlocking: boolean = true;

    @property({
        tooltip: '壁として扱う 2D 物理レイヤーマスク。\n例: 壁グループが 2 番なら (1 << 2) = 4 を設定。',
        visible: function (this: VisionCone) {
            return this.usePhysicsBlocking;
        },
    })
    public blockingLayerMask: number = 0xffffffff;

    @property({
        tooltip: '視界チェックを行う間隔(秒)。\n0 以下なら毎フレームチェックします。',
        min: 0,
    })
    public updateInterval: number = 0.1;

    @property({
        tooltip: '対象が「発見」とみなされるまでの連続可視時間(秒)。\n0 なら即時発見。',
        min: 0,
    })
    public minVisibleTime: number = 0.2;

    @property({
        tooltip: '対象が「ロスト」とみなされるまでの連続不可視時間(秒)。\n0 なら即時ロスト。',
        min: 0,
    })
    public minLostTime: number = 0.2;

    @property({
        tooltip: 'デバッグログを出力するかどうか(発見/ロスト時)。',
    })
    public logDebug: boolean = true;

    // ==== 公開読み取り用プロパティ ====

    /**
     * 現在視界内にいると判定されているターゲットノード一覧。
     * 外部から参照専用として利用してください。
     */
    public get visibleTargets(): readonly Node[] {
        return this._visibleTargets;
    }

    // ==== 内部状態 ====

    private _graphics: Graphics | null = null;
    private _timeAccumulator: number = 0;
    private _targets: TargetState[] = [];
    private _visibleTargets: Node[] = [];

    // 一時変数(GC 削減のため再利用)
    private _tempForward: Vec3 = new Vec3();
    private _tempToTarget: Vec3 = new Vec3();
    private _tempOrigin: Vec3 = new Vec3();
    private _tempTargetPos: Vec3 = new Vec3();

    onLoad() {
        // ターゲット状態初期化
        this._targets = [];
        for (const node of this.targetNodes) {
            if (!node) continue;
            this._targets.push({
                node,
                isVisible: false,
                visibleTime: 0,
                invisibleTime: 0,
            });
        }

        // DebugDraw 用 Graphics の取得/追加
        if (this.enabledDebugDraw) {
            this._graphics = this.getComponent(Graphics);
            if (!this._graphics) {
                this._graphics = this.addComponent(Graphics);
            }
        }

        // PhysicsSystem2D が有効かどうかのチェック(必要なときだけ)
        if (this.usePhysicsBlocking) {
            const physics2D = PhysicsSystem2D.instance;
            if (!physics2D) {
                console.warn('[VisionCone] PhysicsSystem2D が有効ではありません。usePhysicsBlocking=true ですが、遮蔽判定は行われません。');
            }
        }
    }

    start() {
        // シーンの更新に合わせて動作させるため、director の状態を確認
        if (!director.isPaused()) {
            // 特に何もしないが、将来の拡張ポイントとして残しておく
        }
    }

    update(dt: number) {
        // updateInterval で間引き
        if (this.updateInterval > 0) {
            this._timeAccumulator += dt;
            if (this._timeAccumulator < this.updateInterval) {
                // 描画だけ更新したい場合はここで return せずに drawVisionCone を呼ぶ選択肢もある
                return;
            }
            this._timeAccumulator = 0;
        }

        this.updateVision(dt);
        this.drawVisionCone();
    }

    /**
     * 視界判定のメイン処理
     */
    private updateVision(dt: number) {
        // 前方ベクトルの取得(ワールド空間)
        // Node.forward はローカル空間の前方なので、世界空間に変換された forward を利用
        this.node.getWorldPosition(this._tempOrigin);
        this.node.getWorldRotation().getForward(this._tempForward);

        const halfFovRad = math.toRadian(this.fovAngle * 0.5);
        const cosHalfFov = Math.cos(halfFovRad);
        const radiusSqr = this.radius * this.radius;

        const newVisibleTargets: Node[] = [];

        for (const t of this._targets) {
            const target = t.node;
            if (!target || !target.isValid) {
                // 無効なノードはスキップ
                continue;
            }

            // ターゲットのワールド位置
            target.getWorldPosition(this._tempTargetPos);

            // 原点からターゲットへのベクトル
            Vec3.subtract(this._tempToTarget, this._tempTargetPos, this._tempOrigin);

            // 距離チェック
            const distSqr = this._tempToTarget.lengthSqr();
            let isVisibleNow = true;

            if (distSqr > radiusSqr) {
                isVisibleNow = false;
            } else {
                // 角度チェック(内積で cosθ を比較)
                const toTargetNorm = this._tempToTarget.normalize();
                const dot = Vec3.dot(this._tempForward, toTargetNorm);

                if (dot < cosHalfFov) {
                    isVisibleNow = false;
                }
            }

            // 壁による遮蔽チェック
            if (isVisibleNow && this.usePhysicsBlocking && PhysicsSystem2D.instance) {
                const from = new Vec2(this._tempOrigin.x, this._tempOrigin.y);
                const to = new Vec2(this._tempTargetPos.x, this._tempTargetPos.y);

                const results = PhysicsSystem2D.instance.raycast(
                    from,
                    to,
                    ERaycast2DType.Closest,
                    this.blockingLayerMask
                );

                if (results.length > 0) {
                    // レイの途中に遮蔽物があるため、見えないと判断
                    isVisibleNow = false;
                }
            }

            // 可視/不可視時間の更新
            if (isVisibleNow) {
                t.visibleTime += dt;
                t.invisibleTime = 0;
            } else {
                t.invisibleTime += dt;
                t.visibleTime = 0;
            }

            // ステート遷移判定
            if (!t.isVisible && isVisibleNow && t.visibleTime >= this.minVisibleTime) {
                t.isVisible = true;
                if (this.logDebug) {
                    console.log(`[VisionCone] Target FOUND: ${target.name}`);
                }
            } else if (t.isVisible && !isVisibleNow && t.invisibleTime >= this.minLostTime) {
                t.isVisible = false;
                if (this.logDebug) {
                    console.log(`[VisionCone] Target LOST: ${target.name}`);
                }
            }

            if (t.isVisible) {
                newVisibleTargets.push(target);
            }
        }

        this._visibleTargets = newVisibleTargets;
    }

    /**
     * Graphics を用いた視界コーンのデバッグ描画
     */
    private drawVisionCone() {
        if (!this.enabledDebugDraw) {
            return;
        }

        if (!this._graphics) {
            this._graphics = this.getComponent(Graphics);
            if (!this._graphics) {
                console.warn('[VisionCone] enabledDebugDraw=true ですが Graphics コンポーネントが見つかりません。描画は行われません。');
                return;
            }
        }

        const g = this._graphics;
        g.clear();

        // ローカル空間で扇形を描画する。
        // Node の forward を基準に、左右に fovAngle/2 ずつ広げる。
        const segments = 24; // 扇形の分割数
        const halfAngleRad = math.toRadian(this.fovAngle * 0.5);

        // 前方角度(ローカル空間の前方は Z+ だが、2D では Y 軸回転を無視して X-Y 平面で扱う)
        // Graphics はローカル座標で描くので、ここでは単純に角度 0 を前方とし、左右に広げる。
        const startAngle = -halfAngleRad;
        const endAngle = halfAngleRad;

        g.strokeColor = new Color(0, 255, 0, 255);
        g.fillColor = new Color(0, 255, 0, 60);

        g.moveTo(0, 0);

        for (let i = 0; i <= segments; i++) {
            const t = i / segments;
            const angle = startAngle + (endAngle - startAngle) * t;
            const x = Math.cos(angle) * this.radius;
            const y = Math.sin(angle) * this.radius;
            g.lineTo(x, y);
        }

        g.close();
        g.fill();
        g.stroke();
    }

    /**
     * インスペクタ上で targetNodes が変更されたときに呼ばれる可能性のあるフック。
     * Cocos Creator 3.8 では自動では呼ばれないため、必要なら手動で呼び出す。
     * 本コンポーネントでは onLoad 時に一度だけ初期化している。
     */
    public refreshTargetsFromInspector() {
        this._targets = [];
        for (const node of this.targetNodes) {
            if (!node) continue;
            this._targets.push({
                node,
                isVisible: false,
                visibleTime: 0,
                invisibleTime: 0,
            });
        }
    }
}

コードの主要部分の解説

  • onLoad
    ・インスペクタで指定された targetNodes から内部用の _targets 配列(TargetState)を初期化。
    enabledDebugDrawtrue の場合、Graphics コンポーネントを取得し、なければ自動追加。
    usePhysicsBlockingtrue のとき、PhysicsSystem2D の有効性をチェックし、無効なら警告ログ。
  • update
    updateInterval に基づき、視界チェックを間引き。
    ・間隔に達したタイミングで updateVision(dt) を呼び、視界判定を実行。
    ・その後、drawVisionCone() で視界コーンのデバッグ描画を行う。
  • updateVision
    ・ノードのワールド座標とワールド前方ベクトルを取得。
    ・各ターゲットに対して:
    • 距離チェック(radius 以内か)。
    • 角度チェック(前方との内積が cos(fovAngle/2) 以上か)。
    • usePhysicsBlockingtrue なら、PhysicsSystem2D.instance.raycast で壁をチェック。
    • 可視/不可視時間を積算し、minVisibleTime / minLostTime を超えたら isVisible を切り替え。
    • 発見/ロスト時にログ出力(logDebugtrue の場合)。

    ・最終的に「現在見えているターゲット」の配列を _visibleTargets に格納し、visibleTargets getter から参照可能にする。

  • drawVisionCone
    enabledDebugDrawtrue のときだけ実行。
    Graphics でローカル座標系に扇形を描画。
    ・視界の半径と角度に基づいて扇形を線分に分割し、moveTo(0,0) から順に lineTo を行い、fill + stroke

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択し、VisionCone.ts という名前で作成します。
  2. 自動生成されたコードをすべて削除し、本記事の TypeScript コード をそのまま貼り付けて保存します。

2. テスト用シーンの準備

2-1. 敵(視界を持つノード)の作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、Enemy という名前に変更します。
  2. Inspector で Sprite の画像を適当な敵キャラのテクスチャに設定します(なければ色付き四角でも可)。
  3. 回転方向の前方
    • Cocos 2D では、ローカルの「前方」は Z+ ですが、今回の視界コーンは「ノードの右方向(X+)」を前方として扱う形になります(Graphics の描画が X 正方向基準)。
    • 敵の「正面」が右を向いているようにスプライトを配置するか、必要ならスプライトの Rotation を調整してください。

2-2. プレイヤー(検知対象)の作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、Player という名前に変更します。
  2. Inspector で見分けがつくように色や画像を変更します(例:青色)。
  3. シーン上で Enemy の右側あたりに配置しておきます。

2-3. 壁(遮蔽物)の作成と物理設定

壁による遮蔽をテストするには、2D 物理と Collider2D を設定します。

  1. メニューから Project → Project Settings → Physics 2D を開き、2D 物理が有効になっていることを確認します。
  2. Hierarchy で右クリック → Create → 2D Object → Sprite を選び、Wall と名付けます。
  3. Inspector で Add Component → Physics2D → BoxCollider2D を追加します。
  4. BoxCollider2D のサイズを調整し、EnemyPlayer の間に置いて、視線を遮るように配置します。
  5. Physics 2D のグループ設定(Project Settings → Physics 2D → Group)で、Wall 用のグループ(例:WALL)を作成し、Wall ノードの Collider2D の Group をそのグループに設定します。
  6. WALL グループのインデックスが 2 番だった場合、ビットマスクは 1 << 2 = 4 になります(後で VisionCone の blockingLayerMask に設定)。

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

  1. Hierarchy で Enemy ノードを選択します。
  2. Inspector の下部で Add Component → Custom → VisionCone を選択してアタッチします。
  3. Inspector 上の VisionCone プロパティを以下のように設定してみます:
    • Enabled Debug Drawtrue
    • Radius300
    • Fov Angle90
    • Target Nodes:サイズを 1 にして、要素 0 に Player ノードをドラッグ&ドロップ
    • Use Physics Blockingtrue
    • Blocking Layer Mask:壁グループが 2 番なら 41 << 2
    • Update Interval0.1
    • Min Visible Time0.2
    • Min Lost Time0.2
    • Log Debugtrue

4. シーンの再生と動作確認

  1. シーンを保存し、上部の Play(▶) ボタンでゲームを再生します。
  2. Scene ビューまたは Game ビューで、Enemy ノードから前方に緑色の扇形が描画されていることを確認します(enabledDebugDraw = true の場合)。
  3. Player を視界コーンの内側にドラッグしてみてください:
    • 壁がない状態では、プレイヤーがコーン内に入ると、コンソールに [VisionCone] Target FOUND: Player と表示され、visibleTargets 配列に Player が含まれる状態になります。
    • プレイヤーをコーン外に動かすと、一定時間後に [VisionCone] Target LOST: Player と表示されます。
  4. WallEnemyPlayer の間に置き、視線を遮るように動かしてみてください:
    • プレイヤーが視界コーン内にいても、Wall が間にあると 発見されない(または発見済みならロストされる)はずです。
    • Wall をどかすと、再び FOUND ログが出て視認状態になります。

5. 外部からの利用(AI などへの応用)

VisionCone は単体で完結しており、他のマネージャには依存しません。
別のスクリプトから視界情報を使いたい場合は、同じノードにアタッチされた VisionCone を取得し、visibleTargets を参照します。


// 例: EnemyController.ts など、同じ Enemy ノードにアタッチされた別スクリプトから
import { _decorator, Component, Node } from 'cc';
import { VisionCone } from './VisionCone';
const { ccclass, property } = _decorator;

@ccclass('EnemyController')
export class EnemyController extends Component {
    private _vision: VisionCone | null = null;

    start() {
        this._vision = this.getComponent(VisionCone);
        if (!this._vision) {
            console.error('[EnemyController] VisionCone コンポーネントが見つかりません。');
        }
    }

    update(deltaTime: number) {
        if (!this._vision) return;

        const visibleTargets = this._vision.visibleTargets;
        if (visibleTargets.length > 0) {
            // 何かが見えている
            const primaryTarget = visibleTargets[0];
            // ここで追跡処理などを行う
        } else {
            // 何も見えていないときの処理
        }
    }
}

このように、VisionCone 自体は外部に依存せず、視界情報だけを提供する純粋なコンポーネントとして利用できます。


まとめ

  • VisionCone コンポーネントは、任意のノードにアタッチするだけで
    • 前方の扇形視界
    • 壁による遮蔽判定(2D レイキャスト)
    • 発見/ロストの安定化(可視/不可視時間のしきい値)
    • 視界コーンのデバッグ描画

    を提供する、完全独立・再利用可能なスクリプトです。

  • インスペクタで
    • 視界半径・角度
    • 検知対象ノード
    • 壁レイヤーマスク
    • 更新間隔・発見/ロストディレイ
    • デバッグ描画・ログ出力

    を調整できるため、ゲームごとのバランス調整も容易です。

  • このコンポーネントをベースに、
    • 監視カメラの視界
    • ステルスゲームの敵 AI
    • センサー式トラップやレーザー検知

    など、さまざまな仕組みを追加のスクリプトだけで構築できます。

プロジェクト内のどの敵やオブジェクトにもそのままアタッチして使い回せるため、視界判定ロジックを一箇所に集約し、ゲーム開発の効率化と保守性向上に大きく貢献します。
必要に応じて、3D 対応版や複数レイヤーの遮蔽判定などにも発展させてみてください。

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をコピーしました!