【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 物理レイヤーマスク)。
・usePhysicsBlockingがtrueのときだけ使用。
・例:壁用の PhysicsGroup を 2 番にしている場合、1 << 2を指定。updateInterval: number
・視界判定を行う間隔(秒)。
・0 以下の場合は毎フレームupdateで判定。
・負荷軽減のため、0.1~0.2程度を推奨。minVisibleTime: number
・対象が「発見状態」とみなされるまでに必要な連続可視時間(秒)。
・一瞬だけ見えた場合に「発見」としないためのディレイ。minLostTime: number
・対象が「ロスト状態」とみなされるまでに必要な連続不可視時間(秒)。
・視界から一瞬外れてもすぐにはロスト扱いにしない。
内部実装の概要
- ノードの「前方」を
Node.forwardから取得し、ワールド座標系の前方ベクトルを求める。 - 各
targetNodeに対して:- ノードのワールド座標を取得。
- 距離が
radius以下か確認。 - 前方ベクトルとの角度が
fovAngle / 2以内か確認。 usePhysicsBlocking === trueの場合、PhysicsSystem2D.instance.raycastを使って、視線と壁の間に遮蔽物があるかをチェック。- 可視/不可視の継続時間をカウントし、
minVisibleTime/minLostTimeを超えたらステートを切り替える。
- 結果として、「現在見えている対象ノードの配列」を公開プロパティで保持。
enabledDebugDrawがtrueのときは、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)を初期化。
・enabledDebugDrawがtrueの場合、Graphicsコンポーネントを取得し、なければ自動追加。
・usePhysicsBlockingがtrueのとき、PhysicsSystem2Dの有効性をチェックし、無効なら警告ログ。update
・updateIntervalに基づき、視界チェックを間引き。
・間隔に達したタイミングでupdateVision(dt)を呼び、視界判定を実行。
・その後、drawVisionCone()で視界コーンのデバッグ描画を行う。updateVision
・ノードのワールド座標とワールド前方ベクトルを取得。
・各ターゲットに対して:- 距離チェック(
radius以内か)。 - 角度チェック(前方との内積が
cos(fovAngle/2)以上か)。 usePhysicsBlockingがtrueなら、PhysicsSystem2D.instance.raycastで壁をチェック。- 可視/不可視時間を積算し、
minVisibleTime/minLostTimeを超えたらisVisibleを切り替え。 - 発見/ロスト時にログ出力(
logDebugがtrueの場合)。
・最終的に「現在見えているターゲット」の配列を
_visibleTargetsに格納し、visibleTargetsgetter から参照可能にする。- 距離チェック(
drawVisionCone
・enabledDebugDrawがtrueのときだけ実行。
・Graphicsでローカル座標系に扇形を描画。
・視界の半径と角度に基づいて扇形を線分に分割し、moveTo(0,0)から順にlineToを行い、fill+stroke。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択し、
VisionCone.tsという名前で作成します。 - 自動生成されたコードをすべて削除し、本記事の TypeScript コード をそのまま貼り付けて保存します。
2. テスト用シーンの準備
2-1. 敵(視界を持つノード)の作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、
Enemyという名前に変更します。 - Inspector で Sprite の画像を適当な敵キャラのテクスチャに設定します(なければ色付き四角でも可)。
- 回転方向の前方:
- Cocos 2D では、ローカルの「前方」は Z+ ですが、今回の視界コーンは「ノードの右方向(X+)」を前方として扱う形になります(Graphics の描画が X 正方向基準)。
- 敵の「正面」が右を向いているようにスプライトを配置するか、必要ならスプライトの
Rotationを調整してください。
2-2. プレイヤー(検知対象)の作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、
Playerという名前に変更します。 - Inspector で見分けがつくように色や画像を変更します(例:青色)。
- シーン上で
Enemyの右側あたりに配置しておきます。
2-3. 壁(遮蔽物)の作成と物理設定
壁による遮蔽をテストするには、2D 物理と Collider2D を設定します。
- メニューから Project → Project Settings → Physics 2D を開き、2D 物理が有効になっていることを確認します。
- Hierarchy で右クリック → Create → 2D Object → Sprite を選び、
Wallと名付けます。 - Inspector で
Add Component → Physics2D → BoxCollider2Dを追加します。 - BoxCollider2D のサイズを調整し、
EnemyとPlayerの間に置いて、視線を遮るように配置します。 - Physics 2D のグループ設定(Project Settings → Physics 2D → Group)で、
Wall用のグループ(例:WALL)を作成し、Wallノードの Collider2D の Group をそのグループに設定します。 WALLグループのインデックスが 2 番だった場合、ビットマスクは1 << 2 = 4になります(後で VisionCone のblockingLayerMaskに設定)。
3. VisionCone コンポーネントのアタッチ
- Hierarchy で
Enemyノードを選択します。 - Inspector の下部で Add Component → Custom → VisionCone を選択してアタッチします。
- Inspector 上の VisionCone プロパティを以下のように設定してみます:
- Enabled Debug Draw:
true - Radius:
300 - Fov Angle:
90 - Target Nodes:サイズを
1にして、要素 0 にPlayerノードをドラッグ&ドロップ - Use Physics Blocking:
true - Blocking Layer Mask:壁グループが 2 番なら
4(1 << 2) - Update Interval:
0.1 - Min Visible Time:
0.2 - Min Lost Time:
0.2 - Log Debug:
true
- Enabled Debug Draw:
4. シーンの再生と動作確認
- シーンを保存し、上部の Play(▶) ボタンでゲームを再生します。
- Scene ビューまたは Game ビューで、
Enemyノードから前方に緑色の扇形が描画されていることを確認します(enabledDebugDraw = trueの場合)。 Playerを視界コーンの内側にドラッグしてみてください:- 壁がない状態では、プレイヤーがコーン内に入ると、コンソールに
[VisionCone] Target FOUND: Playerと表示され、visibleTargets配列にPlayerが含まれる状態になります。 - プレイヤーをコーン外に動かすと、一定時間後に
[VisionCone] Target LOST: Playerと表示されます。
- 壁がない状態では、プレイヤーがコーン内に入ると、コンソールに
WallをEnemyとPlayerの間に置き、視線を遮るように動かしてみてください:- プレイヤーが視界コーン内にいても、
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 対応版や複数レイヤーの遮蔽判定などにも発展させてみてください。




