2Dアクションやステルスゲームで「敵がプレイヤーを見つける」処理を書くとき、多くの人が最初にやるのは、敵キャラ用のベースシーンを作って、その中に RayCast2D を直置きし、スクリプトをガッツリ書き込むやり方だと思います。
でもこのやり方、少し規模が大きくなると途端にツラくなります。
- 敵の種類ごとにベースシーンを継承して、視界ロジックをコピペ or ちょっと変える
- プレイヤーにも視界を付けたくなったら、また別実装
- RayCast2D のパラメータ(距離・コリジョンレイヤー)を変えたいときに、あちこちのシーンを開いて修正
つまり「視界」という汎用的な機能が、キャラクター固有の実装にベッタリ貼り付いてしまうんですね。
そこで今回は、どのノードにもポン付けできる「VisionCone」コンポーネントとして視界判定を切り出してしまいましょう。
RayCast2D を内側で管理しつつ、ターゲットが見えているかどうかをシンプルな API で取得できるようにします。
【Godot 4】RayCast2Dでスマート視界判定!「VisionCone」コンポーネント
フルコード(GDScript / Godot 4)
extends Node2D
class_name VisionCone
## 親ノードからターゲットが見えているかを判定するコンポーネント。
## 内部で RayCast2D を生成・管理し、壁などの障害物を考慮して
## 「ターゲットが視界に入っているか」をチェックします。
##
## 想定用途:
## - 敵がプレイヤーを見つける
## - 見張り役が侵入者を監視する
## - プレイヤーの「調べる」方向を限定する など
@export var target_path: NodePath:
## 視界判定の対象となるノードへのパス
## 例: "../Player" や、エディタ上で Player ノードをドラッグ&ドロップ
get:
return target_path
set(value):
target_path = value
_update_target()
@export_range(0.0, 2000.0, 1.0)
var max_distance: float = 300.0:
## 視界の最大距離(ピクセル単位)
## RayCast2D の cast_to ベクトルの長さとして使われます
get:
return max_distance
set(value):
max_distance = max(value, 0.0)
_update_ray_cast_length()
@export_range(0.0, 180.0, 1.0)
var fov_degrees: float = 90.0:
## 視野角(Field Of View)を左右合計の角度で指定します。
## 例: 90度なら、前方向から左右45度までが有効。
get:
return fov_degrees
set(value):
fov_degrees = clamp(value, 0.0, 180.0)
@export var use_parent_rotation: bool = true:
## true の場合、親ノードの rotation を「前方向」とみなして視野角を計算します。
## false の場合、この VisionCone ノード自身の rotation を基準にします。
## (例: 親は物理で回転するけど、視界だけ別向きにしたい場合など)
get:
return use_parent_rotation
set(value):
use_parent_rotation = value
@export var collision_mask: int = 1:
## 視界判定に使う RayCast2D の collision_mask。
## 壁や障害物が所属するレイヤーをチェックするために使います。
## 例: 壁をレイヤー1、キャラクターをレイヤー2 にしておき、
## VisionCone の mask は 1 (壁のみ) にしておくと、
## 「壁に遮られているか」だけを綺麗に判定できます。
get:
return collision_mask
set(value):
collision_mask = value
if _ray_cast:
_ray_cast.collision_mask = collision_mask
@export var debug_draw: bool = false:
## true の場合、視界の向きと最大距離を簡易的に描画します。
## ゲーム中に「ちゃんと判定されているか」を確認するためのデバッグ用です。
get:
return debug_draw
set(value):
debug_draw = value
queue_redraw()
## 現在ターゲットが見えているかどうか
var is_target_visible: bool = false:
get:
return is_target_visible
## 内部で使用する RayCast2D
var _ray_cast: RayCast2D
## キャッシュされたターゲットノード
var _target: Node2D
func _ready() -> void:
# 親ノードがいない場合は警告
if not get_parent():
push_warning("VisionCone: 親ノードが存在しません。このコンポーネントは何かの子として使ってください。")
# 内部用の RayCast2D を生成して、この VisionCone の子として追加
_ray_cast = RayCast2D.new()
_ray_cast.name = "VisionRay"
_ray_cast.enabled = true
_ray_cast.collision_mask = collision_mask
add_child(_ray_cast)
_update_ray_cast_length()
_update_target()
func _physics_process(delta: float) -> void:
# ターゲットが設定されていなければ何もしない
if not is_instance_valid(_target):
is_target_visible = false
return
# 視野角チェック
if not _is_target_in_fov():
is_target_visible = false
return
# RayCast2D をターゲット方向に向けて更新
_update_ray_direction()
_ray_cast.force_raycast_update()
# RayCast2D が何かに当たっているか?
if _ray_cast.is_colliding():
var collider := _ray_cast.get_collider()
# ここで「壁」を判定します。
# - もし壁を別レイヤーに置いているなら、collision_mask で制御
# - もしくは、コリジョンのグループ名で判定することもできます
#
# 今回はシンプルに、RayCast2D が何かに当たったら「遮られている」とみなします。
# (ターゲット自身に RayCast が命中するケースを除外するための処理を追加します)
if collider == _target:
# ターゲット自身にヒットしている = 壁に遮られていない
is_target_visible = true
else:
# 壁などに遮られている
is_target_visible = false
else:
# 何にも当たっていない場合:
# 「ターゲットまでの間に壁がない」かつ「ターゲットが距離内」にいるなら見えている。
# ただし RayCast の長さは max_distance なので、
# ターゲットがさらに遠くにいる場合は is_target_visible = false になります。
var distance_to_target := global_position.distance_to(_target.global_position)
is_target_visible = distance_to_target <= max_distance
# デバッグ描画更新
if debug_draw:
queue_redraw()
func _is_target_in_fov() -> bool:
## ターゲットが視野角の範囲内にいるかどうかを判定します。
if not is_instance_valid(_target):
return false
# 前方向ベクトル
var forward_angle := (use_parent_rotation and get_parent() != null) \
? get_parent().global_rotation \
: global_rotation
var forward: Vector2 = Vector2.RIGHT.rotated(forward_angle)
var to_target: Vector2 = (_target.global_position - global_position).normalized()
var angle_between := rad_to_deg(forward.angle_to(to_target)).abs()
return angle_between <= fov_degrees * 0.5
func _update_ray_direction() -> void:
## RayCast2D をターゲット方向へ向ける
if not is_instance_valid(_target):
return
var dir: Vector2 = (_target.global_position - global_position)
var distance := min(dir.length(), max_distance)
if distance <= 0.0:
_ray_cast.target_position = Vector2.ZERO
else:
_ray_cast.target_position = dir.normalized() * distance
func _update_ray_cast_length() -> void:
## RayCast2D の長さを更新します。
if not _ray_cast:
return
# デフォルト方向は右向き(Vector2.RIGHT)
_ray_cast.target_position = Vector2.RIGHT * max_distance
func _update_target() -> void:
## target_path からターゲットノードを取得してキャッシュします。
_target = null
if target_path == NodePath():
return
var node := get_node_or_null(target_path)
if node and node is Node2D:
_target = node
else:
if node:
push_warning("VisionCone: target_path が Node2D ではありません。2D位置を持つノードを指定してください。")
else:
push_warning("VisionCone: target_path からノードを取得できませんでした。パスを確認してください。")
func can_see_target() -> bool:
## 外部から呼び出すための、シンプルなAPI。
## 「今このフレームでターゲットが見えているか?」を返します。
return is_target_visible
func _draw() -> void:
## デバッグ用の簡易描画
if not debug_draw:
return
# 視界の中心線
var forward_angle := (use_parent_rotation and get_parent() != null) \
? get_parent().global_rotation \
: global_rotation
var forward: Vector2 = Vector2.RIGHT.rotated(forward_angle) * max_distance
draw_line(Vector2.ZERO, forward, Color.GREEN, 2.0)
# 視野角の左右の境界線
var half_fov_rad := deg_to_rad(fov_degrees * 0.5)
var left_dir := Vector2.RIGHT.rotated(forward_angle - half_fov_rad) * max_distance
var right_dir := Vector2.RIGHT.rotated(forward_angle + half_fov_rad) * max_distance
draw_line(Vector2.ZERO, left_dir, Color(0, 1, 0, 0.4), 1.0)
draw_line(Vector2.ZERO, right_dir, Color(0, 1, 0, 0.4), 1.0)
# ターゲットまでの実際の RayCast ライン
if _ray_cast:
draw_line(Vector2.ZERO, _ray_cast.target_position, Color.YELLOW, 1.5)
使い方の手順
ここからは、敵がプレイヤーを見つけるという典型的な例で使い方を見ていきましょう。
ノード構成はなるべくシンプルに、「敵のロジック」と「視界判定」を分離します。
例1: 敵がプレイヤーを見つける
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── VisionCone (Node2D) ※このコンポーネントをアタッチ
Player (CharacterBody2D) ├── Sprite2D └── CollisionShape2D
手順①: VisionCone.gd をプロジェクトに追加
- 上記のコードを
res://components/vision_cone.gdなどに保存します。 - Godot 4 のスクリプトインポートで
class_name VisionConeが効いていることを確認します(スクリプトを保存すれば自動で反映されます)。
手順②: 敵シーンに VisionCone ノードを追加
- Enemy シーン(例:
Enemy.tscn)を開きます。 - Enemy (CharacterBody2D) の子として Node2D を追加し、名前を
VisionConeに変更します。 - その Node2D に
VisionConeスクリプトをアタッチします(クラス名のおかげで一覧に出てきます)。
手順③: Inspector からターゲットとパラメータを設定
target_path: シーンツリーから Player ノードをドラッグ&ドロップmax_distance: 例として 400 に設定(敵の視界距離)fov_degrees: 例として 90 に設定(左右45度の視野)collision_mask: 壁のレイヤー番号に合わせる(例: 壁がレイヤー1なら「1」)debug_draw: 動作確認したいときは ON にすると、ゲーム中に視界ラインが表示されます
手順④: 敵のAIスクリプトから視界状態を参照
Enemy 側のスクリプトは、「VisionCone の状態を読むだけ」にします。
視界ロジックをここに書かないのがポイントです。
extends CharacterBody2D
@onready var vision_cone: VisionCone = $VisionCone
var is_alert: bool = false
func _physics_process(delta: float) -> void:
if vision_cone.can_see_target():
if not is_alert:
is_alert = true
print("敵がプレイヤーを発見!")
_chase_player(delta)
else:
if is_alert:
print("プレイヤーを見失った…")
is_alert = false
_patrol(delta)
func _chase_player(delta: float) -> void:
# ここに追跡ロジックを書く(プレイヤーの位置へ移動するなど)
pass
func _patrol(delta: float) -> void:
# ここに巡回ロジックを書く
pass
こうしておけば、Enemy シーンを継承した別の敵でも、まったく別構造のボス敵でも、
「VisionCone を子として付けて、can_see_target() を読むだけ」で視界判定を再利用できます。
例2: 動く床が「プレイヤーが上にいるか」を視界で判定
ちょっと変わった使い方として、「動く床がプレイヤーを感知したら動き出す」というギミックにも使えます。
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── VisionCone (Node2D)
この場合、use_parent_rotation = false にして、VisionCone の rotation を下向きにしておけば、
「床の下方向にいるプレイヤーを感知する」みたいなこともできます。
extends Node2D
@onready var vision_cone: VisionCone = $VisionCone
var is_active := false
func _physics_process(delta: float) -> void:
if vision_cone.can_see_target():
if not is_active:
is_active = true
print("床がプレイヤーを感知して動き出す")
_move_platform(delta)
else:
is_active = false
func _move_platform(delta: float) -> void:
# 床の移動ロジック
pass
メリットと応用
VisionCone をコンポーネントとして切り出すことで、以下のようなメリットがあります。
- シーン構造がスッキリ
敵のスクリプトから「RayCast2D の細かい扱い」が消え、can_see_target()というシンプルな問いかけだけになります。 - 敵・味方・ギミックで使い回し可能
プレイヤー・敵・ギミックなど、「何かを見つける必要があるノード」なら何にでもアタッチ可能です。
継承ツリーに縛られないので、「あとから視界を付けたい」ケースでも楽に対応できます。 - パラメータ調整が楽
max_distanceやfov_degreesを Inspector からいじるだけで、
「この敵は視野が広い」「この見張りは視野が狭い」といった差別化が簡単です。 - レベルデザインの自由度アップ
シーンごとに VisionCone を別配置すれば、「同じ敵でもこの部屋だけ視界が短い」など、
レベルデザイン側での調整がやりやすくなります。
継承ベースで「EnemyBase に視界ロジックを全部書く」スタイルだと、
どうしても EnemyBase に機能が集中して巨大クラス化しがちです。
VisionCone のようにコンポーネント化しておけば、視界ロジックだけを独立してテスト・改造できるのも大きな利点ですね。
改造案: 「最後に見えた位置」を記憶する
もう一歩だけ踏み込んで、ターゲットを見失っても、最後に見えた位置へ向かうような AI を作りたくなると思います。
そのために、VisionCone に「最後に視認した位置」を記録する機能を足してみましょう。
var last_seen_position: Vector2 = Vector2.INF
func _physics_process(delta: float) -> void:
if not is_instance_valid(_target):
is_target_visible = false
return
if not _is_target_in_fov():
is_target_visible = false
return
_update_ray_direction()
_ray_cast.force_raycast_update()
if _ray_cast.is_colliding():
var collider := _ray_cast.get_collider()
if collider == _target:
is_target_visible = true
else:
is_target_visible = false
else:
var distance_to_target := global_position.distance_to(_target.global_position)
is_target_visible = distance_to_target <= max_distance
# ★ ここを追加: 見えている間は位置を記録
if is_target_visible:
last_seen_position = _target.global_position
敵側のスクリプトでは、vision_cone.last_seen_position を見て、
「プレイヤーを見失ったら、その位置まで捜索に行く」といった挙動を実装できます。
こういう小さな機能追加も、VisionCone がコンポーネントとして独立しているおかげで、
敵のクラスを汚さずにスッと書き足せるのが気持ちいいところですね。継承より合成、どんどん進めていきましょう。
