敵AIの「視界判定」、つい継承ベースでガッツリ書き込んでしまいがちですよね。
敵キャラのスクリプトの中に「移動」「アニメーション」「攻撃」「視界チェック」が全部入りになって、少し仕様が変わるたびに継承ツリーをいじる羽目になる…。
さらにGodot標準の作り方だと、敵ごとに RayCast2D をベタ書きしたり、_physics_process() に視界ロジックを直書きしたりして、だんだんカオスになっていきます。

そこで今回は、「視界だけ」をきれいにコンポーネント化した VisionCone(視界コーン)コンポーネント を用意しました。
敵の前方に扇形の Area2D を展開し、壁に遮られていない場合だけ「発見」イベントを飛ばす仕組みです。
どんな敵シーンにもポン付けできるようにしてあるので、「継承より合成」でサクッと視界システムを導入していきましょう。

【Godot 4】敵の視界をコンポーネント化!「VisionCone」コンポーネント

コンセプト

  • 敵の「視界コーン」を 1つの再利用可能コンポーネントとして提供
  • 前方の扇形判定 + RayCast2D による「壁に遮られていないか」チェック
  • プレイヤーなど「見える対象」を Group で指定できる
  • 敵本体のスクリプトは「見つけた」「見失った」の通知だけ受け取ればOK

フルコード:VisionCone.gd


extends Area2D
class_name VisionCone
"""
VisionCone(視界コーン)コンポーネント

敵などの前方に「扇形の視界」を作り、
壁などの障害物に遮られていない場合のみ「発見」扱いにするコンポーネントです。

・このノード自身は Area2D
・子に CollisionShape2D(扇形に近い形)と RayCast2D を持つことを想定しています。
"""

# --- 設定パラメータ(インスペクタから編集)-------------------------

@export var target_group: StringName = &"player":
	## 視界で検知したいターゲットが所属するグループ名
	## 例: "player", "ally", "detectable" など
	set(value):
		target_group = value

@export var vision_angle_deg: float = 90.0:
	## 視界の角度(度数法)。左右に半分ずつ広がるイメージ。
	## 例: 90 → 前方45°〜45°
	set(value):
		vision_angle_deg = max(1.0, min(value, 360.0))

@export var vision_distance: float = 200.0:
	## 視界の最大距離(ピクセル単位)
	set(value):
		vision_distance = max(0.0, value)

@export var obstacle_mask: int = 1:
	## 「視界を遮る障害物」のコリジョンマスク
	## 壁TileMapや障害物に対応するレイヤーを指定してください。
	set(value):
		obstacle_mask = value
		if is_instance_valid(_raycast):
			_raycast.collision_mask = obstacle_mask

@export var debug_draw: bool = false:
	## デバッグ用に視界コーンとレイを描画するかどうか
	set(value):
		debug_draw = value
		queue_redraw()

@export var auto_face_target: bool = false:
	## true の場合、ターゲットの方向に自動で向きを変える(デバッグ・検証用)
	set(value):
		auto_face_target = value

# --- シグナル ----------------------------------------------------

signal target_spotted(target: Node2D)   ## ターゲットを新たに発見したとき
signal target_lost(target: Node2D)      ## 見えていたターゲットを見失ったとき

# --- 内部状態 ----------------------------------------------------

var _visible_targets: = {}  # Dictionary: Node2D -> bool (true: 現在見えている)
var _raycast: RayCast2D     # 壁チェック用の RayCast2D への参照

# キャッシュ用
var _half_angle_rad: float
var _vision_distance_sq: float

func _ready() -> void:
	# 子ノードから RayCast2D を探す
	_raycast = get_node_or_null("RayCast2D")
	if _raycast == null:
		push_warning("VisionCone: 子ノードに RayCast2D が見つかりません。障害物チェックが無効になります。")
	else:
		_raycast.collision_mask = obstacle_mask
	
	_update_cached_values()
	# Area2D のコリジョンレイヤ/マスクは、ターゲット側の設定に合わせてください。
	# 基本的には「ターゲットとだけ衝突する」ようにしておくと扱いやすいです。
	
	# すでにエリア内にいるターゲットがいれば拾っておく
	for body in get_overlapping_bodies():
		_try_register_target(body)
	for area in get_overlapping_areas():
		_try_register_target(area)

func _physics_process(delta: float) -> void:
	if auto_face_target:
		_auto_face_nearest_target()
	
	_update_cached_values()
	
	# 現在登録されているターゲットについて可視性をチェック
	var to_remove: Array = []
	for target: Node2D in _visible_targets.keys():
		if not is_instance_valid(target):
			to_remove.append(target)
			continue
		
		var visible := _is_target_visible(target)
		var was_visible: bool = _visible_targets[target]
		
		if visible and not was_visible:
			_visible_targets[target] = true
			emit_signal("target_spotted", target)
		elif not visible and was_visible:
			_visible_targets[target] = false
			emit_signal("target_lost", target)
	
	for t in to_remove:
		_visible_targets.erase(t)
	
	if debug_draw:
		queue_redraw()

func _update_cached_values() -> void:
	_half_angle_rad = deg_to_rad(vision_angle_deg) * 0.5
	_vision_distance_sq = vision_distance * vision_distance

# --- Area2D コールバック -----------------------------------------

func _on_body_entered(body: Node) -> void:
	_try_register_target(body)

func _on_body_exited(body: Node) -> void:
	_unregister_target(body)

func _on_area_entered(area: Area2D) -> void:
	_try_register_target(area)

func _on_area_exited(area: Area2D) -> void:
	_unregister_target(area)

# --- ターゲット登録/解除 ---------------------------------------

func _try_register_target(node: Node) -> void:
	# Group に属していないものは無視
	if not node is Node2D:
		return
	if not node.is_in_group(target_group):
		return
	
	var target := node as Node2D
	
	# 初回は「見えているか」を判定して状態を登録
	var visible := _is_target_visible(target)
	_visible_targets[target] = visible
	
	if visible:
		emit_signal("target_spotted", target)

func _unregister_target(node: Node) -> void:
	if not node is Node2D:
		return
	if not _visible_targets.has(node):
		return
	
	var was_visible: bool = _visible_targets[node]
	_visible_targets.erase(node)
	
	if was_visible:
		emit_signal("target_lost", node)

# --- 視界判定のコアロジック -------------------------------------

func _is_target_visible(target: Node2D) -> bool:
	# 位置関係を計算
	var to_target: Vector2 = target.global_position - global_position
	var dist_sq := to_target.length_squared()
	
	# 距離チェック
	if dist_sq > _vision_distance_sq:
		return false
	
	# 角度チェック
	# VisionCone の「右方向(Vector2.RIGHT)」を前方とみなす
	var forward := Vector2.RIGHT.rotated(global_rotation)
	var dir := to_target.normalized()
	
	var angle := forward.angle_to(dir)
	if abs(angle) > _half_angle_rad:
		return false
	
	# 障害物チェック(RayCast2D がある場合のみ)
	if _raycast != null:
		_raycast.global_position = global_position
		_raycast.target_position = to_target
		_raycast.enabled = true
		_raycast.force_raycast_update()
		
		if _raycast.is_colliding():
			# 何かに当たっている場合、そのコライダがターゲット本人かどうかを確認
			var collider := _raycast.get_collider()
			if collider != target:
				# 壁などに遮られているので見えない
				return false
	
	return true

# --- デバッグ描画 ------------------------------------------------

func _draw() -> void:
	if not debug_draw:
		return
	
	# 視界コーンを簡易的に描画
	var color := Color(1, 1, 0, 0.2)  # 半透明の黄色
	var border_color := Color(1, 1, 0, 0.8)
	
	var points: Array[Vector2] = []
	points.append(Vector2.ZERO)
	
	var steps := 24
	var start_angle := -_half_angle_rad
	var end_angle := _half_angle_rad
	for i in range(steps + 1):
		var t := float(i) / float(steps)
		var a := lerp(start_angle, end_angle, t)
		points.append(Vector2.RIGHT.rotated(a) * vision_distance)
	
	draw_colored_polygon(points, color)
	draw_polyline(points, border_color, 2.0)
	
	# RayCast の線も描画(最近の1本だけ)
	if _raycast != null:
		draw_line(Vector2.ZERO, _raycast.target_position, Color(1, 0, 0, 0.7), 1.5)

# --- ユーティリティ ----------------------------------------------

func get_visible_targets() -> Array[Node2D]:
	## 現在「見えている」と判定されているターゲット一覧を返す
	var result: Array[Node2D] = []
	for target: Node2D in _visible_targets.keys():
		if _visible_targets[target] and is_instance_valid(target):
			result.append(target)
	return result

func _auto_face_nearest_target() -> void:
	## デバッグ用: 一番近いターゲットの方向を向く
	var nearest: Node2D = null
	var nearest_dist_sq := INF
	
	for target: Node2D in _visible_targets.keys():
		if not is_instance_valid(target):
			continue
		var d_sq := global_position.distance_squared_to(target.global_position)
		if d_sq < nearest_dist_sq:
			nearest_dist_sq = d_sq
			nearest = target
	
	if nearest != null:
		var to_target := nearest.global_position - global_position
		global_rotation = to_target.angle()

重要: VisionConeArea2D シグナル body_entered / body_exited / area_entered / area_exited を、それぞれ _on_body_entered / _on_body_exited / _on_area_entered / _on_area_exited に接続してください。


使い方の手順

① プレイヤー側にグループを設定する

まず、プレイヤー(見つけられたい側)のノードにグループを設定します。

  1. プレイヤーシーンを開く
  2. ルートノード(例: Player (CharacterBody2D))を選択
  3. インスペクタ横の「Node」タブ → Groups → player など任意のグループ名を追加

② VisionCone シーンを作る

VisionCone を単体シーンとして作っておくと、どの敵にもポン付けできて便利です。

  1. 新規シーン → ルートに Area2D を追加
  2. ルートに VisionCone.gd をアタッチ
  3. 子ノードとして CollisionShape2DRayCast2D を追加
  4. CollisionShape2D には CapsuleShape2DRectangleShape2D を使って、前方に細長い扇形っぽい当たり判定を作る
  5. RayCast2Dposition = (0, 0) のままでOK(スクリプト側で動かします)

シーン構成例:

VisionCone (Area2D)
 ├── CollisionShape2D
 └── RayCast2D

このシーンを VisionCone.tscn として保存しておきましょう。

③ 敵シーンに VisionCone をアタッチする

次に、敵キャラのシーンに VisionCone を組み込みます。

  1. 敵シーンを開く(例: Enemy (CharacterBody2D)
  2. 「インスタンスシーン」から先ほどの VisionCone.tscn を追加
  3. VisionCone ノードの位置を、敵の目のあたりに配置
  4. VisionCone のインスペクタで以下を設定
    • target_group: player
    • vision_angle_deg: 90〜120 くらい
    • vision_distance: 200〜400 くらい
    • obstacle_mask: 壁TileMapなどのレイヤに対応するマスク
    • debug_draw: 開発中は ON にすると視界が見えて便利

敵シーン構成の例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── VisionCone (Area2D, コンポーネント)
      ├── CollisionShape2D
      └── RayCast2D

敵スクリプト側では、VisionCone のシグナルを受け取って行動を切り替えるだけです。


# Enemy.gd (例)
extends CharacterBody2D

@onready var vision_cone: VisionCone = $VisionCone

var is_alerted: bool = false
var current_target: Node2D = null

func _ready() -> void:
	vision_cone.target_spotted.connect(_on_target_spotted)
	vision_cone.target_lost.connect(_on_target_lost)

func _on_target_spotted(target: Node2D) -> void:
	is_alerted = true
	current_target = target
	print("敵がターゲットを発見!: ", target.name)

func _on_target_lost(target: Node2D) -> void:
	# ここでは「今追っているターゲット」を見失ったときだけ解除
	if target == current_target:
		is_alerted = false
		current_target = null
		print("敵がターゲットを見失った: ", target.name)

func _physics_process(delta: float) -> void:
	if is_alerted and current_target:
		# 追跡ロジックの例
		var dir := (current_target.global_position - global_position).normalized()
		velocity = dir * 80.0
	else:
		# パトロールなど
		velocity = Vector2.ZERO
	
	move_and_slide()

④ 他の用途(動く監視カメラ・レーザーセンサーなど)にも再利用

VisionCone は「視界=前方の扇形 + 障害物チェック」という汎用コンポーネントなので、敵以外にも簡単に流用できます。

  • 監視カメラ: Node2D に VisionCone を付けて、ゆっくり左右に回転させる
  • 動く床のセンサー: プレイヤーが視界に入ったら動き出すトラップ
  • タレット: 視界に入ったターゲットに対してのみ射撃する砲台

監視カメラシーン例:

SecurityCamera (Node2D)
 ├── Sprite2D
 └── VisionCone (Area2D)
      ├── CollisionShape2D
      └── RayCast2D

メリットと応用

この VisionCone コンポーネントを使うことで、敵AIの視界ロジックを 完全に外出し できます。

  • 敵ごとに視界ロジックをコピペする必要がない
  • 視界の角度・距離・障害物レイヤをインスペクタから調整できるので、レベルデザインが楽
  • 「敵の種類ごとに視界を変える」場合でも、コンポーネントのパラメータを変えるだけでOK
  • 敵本体は「見つけた」「見失った」という 高レベルなイベント だけを扱えばよい

特に、Godot標準の「ノード継承で敵を増やしていく」スタイルだと、
EnemyBase.gd に視界コードを書き、そこから EnemyGuard.gd, EnemySniper.gd…と継承していくうちに、「一部の敵だけ視界距離を変えたい」「この敵は360°見えるようにしたい」といった要望が出て破綻しがちです。

VisionCone をコンポーネントとして分離しておけば、

  • ガードは vision_angle_deg = 90, vision_distance = 200
  • スナイパーは vision_angle_deg = 45, vision_distance = 600
  • レーダー塔は vision_angle_deg = 360, vision_distance = 800

のように、スクリプトをいじらずインスペクタだけで調整できます。まさに「継承より合成」ですね。

改造案:視界内にいる「最も近いターゲット」を取得する

例えば、視界内に複数のプレイヤー(Co-opゲームなど)がいる場合、
「一番近いターゲットだけをロックオンしたい」というケースもあります。
その場合は、VisionCone に次のようなユーティリティ関数を追加すると便利です。


func get_nearest_visible_target() -> Node2D:
	## 現在視界に入っているターゲットのうち、最も近いものを返す
	## いなければ null を返す
	var nearest: Node2D = null
	var nearest_dist_sq := INF
	
	for target in get_visible_targets():
		var d_sq := global_position.distance_squared_to(target.global_position)
		if d_sq < nearest_dist_sq:
			nearest_dist_sq = d_sq
			nearest = target
	
	return nearest

敵側では、


var t := vision_cone.get_nearest_visible_target()
if t:
	# こいつだけを狙う

のように呼び出すだけで、「一番近い敵を狙う」ロジックを簡単に組み立てられます。
このように、小さなユーティリティを足していくだけで、VisionCone コンポーネントはどんどん強力な「視界モジュール」になっていきます。

敵の知覚システムをコンポーネントとして切り出しておくと、
後から「音の検知コンポーネント」「匂いの検知コンポーネント」などを追加するのも楽になります。
ぜひ、自分のプロジェクト流の「合成ベースAI」を育ててみてください。