概要
動作確認環境: Godot 4.3+
ステルスゲームでは「敵の視界に入ったら発見される」仕組みが必要です。Metal Gear SolidやHitmanのようなゲームを思い浮かべてください。敵の正面に立てば見つかりますが、背後からこっそり近づけば気づかれません。この「前方だけを見る」仕組みが FOV(Field of View: 視野角) です。
単純な距離判定だけでは、敵の真後ろにいても検知されてしまい、ステルスゲームとして成立しません。「敵からの距離」「敵の視線方向との角度」「間に壁があるか」――この3つすべてを考慮して初めて、リアルな視界判定になります。
この記事では、 Area3D (検知範囲) + ドット積 (角度判定) + RayCast3D (遮蔽チェック)の3段階で視界システムを構築する方法を解説します。コード量は多めですが、基本パターンを理解すれば応用は簡単です。
視界判定の3段階構造
「敵の正面にいるけど壁越しだから見えない」「す ぐ近くだけど背後だから気づかれない」――こうした状況を正しく判定するために、視界システムは以下の3段階で効率よく処理します。
| 段階 | 手法 | 目的 |
|---|---|---|
| 1. 範囲チェック | Area3D (CollisionShape3D) | 検知範囲内にいるか |
| 2. 角度チェック | ドット積 (dot product) | 視野角内にいるか |
| 3. 遮蔽チェック | RayCast3D | 壁などに遮られていないか |
段階を分ける理由: Area3D で範囲外のターゲットを早期除外すると、高コストな RayCast3D の呼び出し回数を減らせます。たとえば100体のNPCがいても、Area3D内にいるのが3体なら、レイキャストは3回で済みます。
ドット積(dot product)とは?
「ドット積」という言葉に馴染みがなくても心配ありません。ここで使うのは、 2つの方向がどれだけ同じ方向を向いているか を数値で表す計算です。
- 1.0: 完全に同じ方向(正面、0度)
- 0.5: 少しずれている(60度)
- 0.0: 直角(真横、90度)
- -1.0: 正反対の方向(真後ろ、180度)
FOVの判定では、「敵の正面方向」と「敵からターゲットへの方向」のドット積を計算します。たとえば視野角120度の敵なら、半角は60度で cos(60°) = 0.5 です。ドット積が0.5以上なら視野内、0.5未満なら視野外と判定します。
シーンツリー構成
Enemy (CharacterBody3D)
├── FOVDetector (Node3D)
│ ├── DetectionArea (Area3D)
│ │ └── CollisionShape3D (SphereShape3D, radius=15)
│ └── RayCast3D
├── MeshInstance3D
└─ ─ CollisionShape3D
基本的なFOV検知の実装
3段階の構造を理解したところで、実際のスクリプトを見てみましょう。ここからが本記事の核心部分です。Area3Dで範囲に入ったターゲットに対して、ドット積で角度を確認し、RayCast3Dで壁の遮蔽をチェックします。このスクリプト1つで、敵が「前方だけを見る」視界システムが完成します。
# fov_detector.gd
extends Node3D
@export var fov_angle: float = 120.0 # 視野角(度)
@export var detection_range: float = 15.0 # 検知距離
@onready var detection_area: Area3D = $DetectionArea
@onready var ray: RayCast3D = $RayCast3D
# 検知範囲内にいるターゲット候補
var targets_in_range: Array[Node3D] = []
signal target_detected(target: Node3D)
signal target_lost(target: Node3D)
func _ready() -> void:
detection_area.body_entered.connect(_on_body_entered)
detection_area.body_exited.connect(_on_body_exited)
ray.enabled = false # 手動で使用
# 前回の検知状態を保持(状態変化時のみシグナル発火)
var _detected_targets: Array[Node3D] = []
func _physics_process(_delta: float) -> void:
for target in targets_in_range:
var is_visible = _can_see_target(target)
var was_visible = target in _detected_targets
if is_visible and not was_visible:
_detected_targets.append(target)
target_detected.emit(target)
elif not is_visible and was_visible:
_detected_targets.erase(target)
target_lost.emit(target)
func _can_see_target(target: Node3D) -> bool:
var to_target = (target.global_position - global_position)
var distance = to_target.length()
# 距離チェック
if distance > detection_range:
return false
# 角度チェック(ドット積)
var forward = -global_transform.basis.z.normalized()
var direction = to_target.normalized()
var dot = forward.dot(direction)
# FOVの半角のcosと比較
var half_angle_cos = cos(deg_to_rad(fov_angle / 2.0))
if dot < half_angle_cos:
return false # 視野角の外
# 遮蔽チェック(RayCast3D)
ray.target_position = to_target
ray.force_raycast_update()
if ray.is_colliding():
var collider = ray.get_collider()
return collider == target # ターゲット自体に当たったか
return true # レイが何にも当たらない = 遮蔽物なし(検知範囲は距離チェックで制限済み)
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
targets_in_range.append(body)
func _on_body_exited(body: Node3D) -> void:
targets_in_range.erase(body)
if body in _detected_targets:
_detected_targets.erase(body)
target_lost.emit(body)
コードの流れを理解する
このスクリプトは以下の流れで動作します。
_ready(): Area3Dのbody_entered/body_exitedシグナルを接続。プレイヤーが範囲に入ったらtargets_in_rangeに追加し、出たら削除します_physics_process(): 毎フレーム、範囲内の全ターゲットに対して_can_see_target()を呼び出し、前フレームと状態が変わったときだけシグナルを発火します_can_see_target(): 3段階の判定を順番に実行します- 距離チェック:
detection_rangeより遠ければ即false - 角度チェック: ドット積で視野角内かを判定
- 遮蔽チェック: RayCast3D でターゲットまでの間に壁がないかを確認
- 距離チェック:
_detected_targets 配列で前フレームの状態を保持しているのがポイントです。これにより「見えた瞬間」と「見失った瞬間」だけシグナルが発火し、毎フレーム重複して発火することを防いでいます。
tips:
forward方向は-global_transform.basis.zです。Godot の 3D 空間では Z 軸マイナス方向が「前」になります。
tips: RayCast3D の
collision_maskを正しく設定してください。壁や障害物のレイヤーとプレイヤーのレイヤーの両方を含め、検知対象外のオブジェクト(装飾品など)は除外します。
複数レイキャストで精度を上げる
基本的なFOV検知はうまく機能しますが、1本のレイだけでは限界があります。たとえば、プレイヤーが低い壁の陰にしゃがんでいる場合、足元にレイを飛ばすと壁に遮られますが、頭は見えている――そんなケースを検知できません。
複数ポイント(頭・胸・足元)にレイを飛ばすことで、ターゲットの一部でも見えていれば検知できるようになります。基本版の _can_see_target を置き換える形で使います。
# 複数ポイントへの視線チェック(基本版の_can_see_targetを置き換え)
func _can_see_target_multi(target: Node3D) -> bool:
var to_target = target.global_position - global_position
# 距離チェック(基本版と同じ)
if to_target.length() > detection_range:
return false
# 角度チェック(前述と同じ)
var forward = -global_transform.basis.z.normalized()
var dot = forward.dot(to_target.normalized())
if dot < cos(deg_to_rad(fov_angle / 2.0)):
return false
# 複数のチェックポイント(頭、胸、足元)
var check_offsets = [
Vector3(0, 1.7, 0), # 頭
Vector3(0, 1.0, 0), # 胸
Vector3(0, 0.1, 0), # 足元
]
for offset in check_offsets:
var check_pos = target.global_position + offset
var direction = check_pos - global_position
ray.target_position = direction
ray.force_raycast_update()
if ray.is_colliding():
if ray.get_collider() == target:
return true # 1箇所でも見えていればOK
else:
return true # 遮蔽物なし
return false # すべてのポイントが遮蔽されている
tips:
check_offsetsのY値は CharacterBody3D の原点位置(通常は足元)を基準としたオフセットです。キャラクターモデルの原点が中心にある場合は、オフセット値を調整してください。
基本版の _can_see_target との違いは、レイを1本ではなく3本飛ばし、 いずれか1本でもターゲットに到達すれば検知成功 とする点です。これにより、壁の陰にしゃがんでいても頭が見えていれば発見される、というリアルな判定になります。
警戒レベルの段階管理
視界判定ができたら、次はその結果をどう使うかです。Metal Gear SolidやHitmanのようなステルスゲームでは、敵に見つかった瞬間にゲームオーバーではなく、「?」マークが出て少しずつ警戒度が上がる演出が定番ですよね。この段階的な警戒レベルの変化を実装してみましょう。
この AlertSystem は3つの状態を持ちます。
| 状態 | 意味 | ゲームプレイ |
|---|---|---|
| UNAWARE | 気づいていない | 通常の巡回行動 |
| SUSPICIOUS | 何か気配を感じた | 立ち止まって周囲を見回す |
| ALERT | 発見した! | プレイヤーを追跡・攻撃 |
疑念レベル(suspicion_level)は、ターゲットが見えている間は増加し、見失うと徐々に減少します。この値が閾値(alert_threshold)に達すると ALERT 状態に移行する仕組みです。
# alert_system.gd
extends Node
enum AlertState { UNAWARE, SUSPICIOUS, ALERT }
@export var suspicion_rate: float = 30.0 # 疑念の上昇速度(%/秒)
@export var alert_threshold: float = 100.0 # 警戒に移行する閾値
@export var suspicion_decay: float = 15.0 # 疑念の減少速度(%/秒)
var current_state: AlertState = AlertState.UNAWARE
var suspicion_level: float = 0.0
signal state_changed(new_state: AlertState)
func update_detection(is_visible: bool, delta: float) -> void:
if is_visible:
suspicion_level += suspicion_rate * delta
else:
suspicion_level -= suspicion_decay * delta
suspicion_level = clampf(suspicion_level, 0.0, alert_threshold)
_evaluate_state()
func _evaluate_state() -> void:
var new_state: AlertState
if suspicion_level >= alert_threshold:
new_state = AlertState.ALERT
elif suspicion_level > 0.0:
new_state = AlertState.SUSPICIOUS
else:
new_state = AlertState.UNAWARE
if new_state != current_state:
current_state = new_state
state_changed.emit(current_state)
update_detection() が毎フレーム呼ばれるたびに、suspicion_level が増減します。clampf() で0〜閾値の範囲に収め、_evaluate_state() で現在の疑念レベルに応じた状態を判定します。状態が変わったときだけ state_changed シグナルを発火するので、「SUSPICIOUS に変わった瞬間に?マークを表示」といった演出を簡単に実装できます。
FOVDetector との連携
AlertSystemが単体で動いても意味がありません。先ほど実装したFOVDetectorと組み合わせて、「見えている間だけ疑念が上がる」という敵AIの挙動を制御しましょう。
# enemy_ai.gd
extends CharacterBody3D
@onready var fov: Node3D = $FOVDetector
@onready var alert: Node = $AlertSystem
func _ready() -> void:
fov.target_detected.connect(_on_target_detected)
fov.target_lost.connect(_on_target_lost)
alert.state_changed.connect(_on_state_changed)
var _seeing_target: bool = false
func _on_target_detected(_target: Node3D) -> void:
_seeing_target = true
func _on_target_lost(_target: Node3D) -> void:
_seeing_target = false
func _physics_process(delta: float) -> void:
alert.update_detection(_seeing_target, delta)
func _on_state_changed(new_state) -> void:
match new_state:
AlertSystem.AlertState.UNAWARE:
print("通常巡回に復帰")
AlertSystem.AlertState.SUSPICIOUS:
print("何かいた...? 調査開始")
AlertSystem.AlertState.ALERT:
print("発見! 追跡開始")
ベストプラクティス
ここまでの実装を実際のプロジェクトに組み込む際に、パフォーマンスとゲームプレイの質を両立するためのポイントをまとめます。
| 項目 | 推奨 | 理由 |
|---|---|---|
| 検知頻度 | 毎フレームではなくTimerで0.1〜0.2秒間隔 | パフォーマンス向上 |
| レイキャストの数 | 2〜3本 | 多すぎると重い、少なすぎると不正確 |
| コリジョンレイヤー | 専用レイヤーを設定 | 不要な衝突判定を除外 |
| 検知範囲の形状 | SphereShape3D | FOVより大きめに設定し早期除外に使う |
| 警戒レベル | 3段階(UNAWARE/SUSPICIOUS/ALERT) | プレイヤーに対処の猶予を与える |
| デバッグ | ImmediateMesh や @tool スクリプトで視野円錐を可視化 | 調整が格段に楽になる |
Timer による検知頻度の最適化
_physics_process で毎フレーム視界チェックを行うと、敵が多い場面ではパフォーマンスに影響します。Timer を使って0.1〜0.2秒間隔で実行するだけで、処理負荷を大幅に軽減できます。
# _physics_process の代わりに Timer で検知ループを回す
func _ready() -> void:
detection_area.body_entered.connect(_on_body_entered)
detection_area.body_exited.connect(_on_body_exited)
ray.enabled = false
# 検知用タイマー(0.15秒間隔)
var timer = Timer.new()
timer.wait_time = 0.15
timer.timeout.connect(_check_visibility)
add_child(timer)
timer.start()
func _check_visibility() -> void:
for target in targets_in_range:
var is_visible = _can_see_target(target)
var was_visible = target in _detected_targets
if is_visible and not was_visible:
_detected_targets.append(target)
target_detected.emit(target)
elif not is_visible and was_visible:
_detected_targets.erase(target)
target_lost.emit(target)
60FPSの場合、毎フレーム実行では1秒に60回チェックしますが、0.15秒間隔なら約7回で済みます。敵が10体いても70回/秒で、体感的な検知精度はほぼ変わりません。
デバッグ用の視野可視化
視野角やレンジの調整には、実際の視野範囲を目で見て確認できると便利です。Godot 4 には組み込みの DebugDraw クラスはありませんが、 ImmediateMesh や @tool スクリプトを使ってエディタ上で視野範囲をプレビューできます。
# @tool を付けるとエディタ上でも実行される
@tool
extends Node3D
@export var fov_angle: float = 120.0
@export var detection_range: float = 15.0
@export var show_debug: bool = true
func _process(_delta: float) -> void:
if show_debug and Engine.is_editor_hint():
queue_redraw() # 3D描画を更新
tips: 開発中は
show_debugをtrueにして視野範囲を可視化し、リリース時はfalseにするか@toolを外すとよいでしょう。ImmediateMeshで扇形を描画する詳しい方法は公式ドキュメントを参照してください。
まとめ
- Area3D (範囲) + ドット積 (角度) + RayCast3D (遮蔽)の3段階で効率的なFOV判定を実現する
- ドット積で
forward.dot(direction)を計算し、cos(半角)と比較して視野角内かを判定する - 複数レイキャスト(頭・胸・足)でターゲットの一部が見えているケースにも対応できる
_detected_targetsで状態を追跡し、変化時のみシグナルを発火することで重複通知を防ぐ- 警戒レベルを段階的に管理し、即発見 ではなく疑念の蓄積でステルスの緊張感を演出する
- Timer による検知頻度の制限とコリジョンレイヤーの活用でパフォーマンスを最適化する