敵AIの気づく距離や、いま向かっている先を確かめたいときは、実行中の画面に円や線で描くと一目で分かります。Godot では、ノードの _draw() に数行書くだけで、円・線・文字を画面に描けます。
print で距離を出しても、180、150 と数字が流れるだけで、気づく範囲がどこまで届いているのかは見えません。描いておけば、範囲や向きのずれがその場で見つかります。
この記事では、_draw() で敵AIの気づく距離・向かう先・状態を画面に描き、当たり判定の表示と重ねてずれを見つける方法を解説します。
この記事でわかること
_draw()が呼ばれるときと、queue_redraw()の使い方- 描くときの座標と、相手の位置を
to_local()で渡す方法- Debug メニューの表示と、自分で描く表示の使い分け
- 描画用の子ノードを分ける理由
- 実践:見張りの敵に円・線・状態名を描き、気づかない原因を見つけて直す
動作確認環境: Godot 4.7
_draw() と queue_redraw()
_draw() は、_process() のように毎フレーム呼ばれる関数ではありません。呼ばれるのは、ノードが初めて画面に出るとき、非表示から表示に戻ったとき、queue_redraw() で描き直しを頼んだときなどです。描いた絵は、次に描き直すまでそのまま使われます。

描いた円・線・文字は、ノードに貼り付いたまま一緒に動きます。変わらないのは、描いたときの値で決まる部分です 。プレイヤーへ向けた線の向きと長さ、状態の文字の中身がそうです。こうした値が変わるなら、_process() で毎フレーム queue_redraw() を呼びます。同じノードで同じフレームに何度呼んでも、描き直しは1回にまとめられます。
func _process(_delta: float) -> void:
queue_redraw() # 値が変わるので、毎フレーム描き直しを頼む
func _draw() -> void:
draw_circle(Vector2.ZERO, 200.0, Color.SKY_BLUE, false, 2.0) # 輪郭だけの円
描く関数は _draw() の中で呼びます。_process() で直接 draw_circle() を呼ぶと、円は描かれず、「Drawing is only allowed inside this node's _draw()...」というエラーが出ます。Godot 3 の記事にある update() は、4 では queue_redraw() に名前が変わりました。
他のエンジンでいうと: 状態を図形で確かめる用途では、Unity の Gizmos(
OnDrawGizmos)や UE の Draw Debug にあたります。ただし Godot の_draw()はふつうの2D描画なので、製品版で消したい表示は自分で消します。
描くときの座標(to_local())
_draw() の座標は、描いているノードを原点にした位置(ローカル座標)です。Vector2.ZERO はそのノード自身の位置を指します。敵のまわりに円を描くなら、中心は Vector2.ZERO です。
つまずくのは、相手の位置へ線を引くときです。プレイヤーの global_position(ワールド全体の原点から測った位置)を、そのまま終点に渡したとします。線は、描いているノードの位置のぶんだけずれた場所を指します(ノード を回転や拡大していれば、そのぶんもずれます)。終点は to_local() で、描いているノードから見た位置に直してから渡します。
draw_line(Vector2.ZERO, to_local(player.global_position), Color.SKY_BLUE, 2.0)

Debug メニューの表示(Visible Collision Shapes)
エディタ上部の Debug メニューには、エンジンが自分で描いてくれる表示が並んでいます。敵AIを調べるときにまず使うのは Visible Collision Shapes です。押し返さずに重なった相手を知らせる Area2D の範囲も含めて、CollisionShape2D の形を半透明で描きます。ほかに、道すじの Visible Paths、ナビゲーションの Visible Navigation と Visible Avoidance もあります。
チェックは、ゲームを実行するときに読まれます。実行中に切り替えたときは、止めて実行し直します。
エンジンが描くのは、当たり判定のような「エンジンが知っている形」です。敵の気づく距離の変数や、いま向かっている先、状態の名前は、エンジンは知りません。そこを自分で描きます。

2つを重ねると、描いた値と実際の判定が同じかどうかを、画面で見比べられます。自分で描いた円と、エンジンが描く当たりの円が重ならないなら、変数と判定のどちらかがずれています。
描画用の子ノード(DebugDraw)
描く処理は敵のスクリプトに書かず、DebugDraw という描画用の子ノードに分けます。
理由の1つは重なり順です。親が _draw() で描いた絵には、子の Sprite2D が上から重なります。敵本体に円を描くと、重なった部分がスプライトに隠れます。Scene ドックで DebugDraw を Sprite2D より下に並べると、あとから描かれるぶん、スプライトの上に重なります。
もう1つは、出し入れのしやすさです。描く処理が1つのノードにまとまっていれば、visible を切り替えるだけで消せます。queue_redraw() も、_draw() を持っている DebugDraw 自身で呼びます。敵の側で呼んでも、DebugDraw は描き直されません。書き出すときの保存画面には、開発用の機能を含めるかどうかの Export With Debug があります。これを外して書き出したゲームでは、開発用の版かどうかを返す OS.is_debug_build() が false になります。そこで _ready() で、ノードごと消しておきます。エディタから実行しているあいだは true なので、円はそのまま出ます。

実践:見張りの敵に、気づく距離・向かう先・状態を描く
ステルスゲームの見張り、見下ろしアクションの巡回する敵、タワーディフェンスの砲台——どれも「どこまで気づくか」「いまどこへ向かうか」「いまどの状態か」の3つで動いています。巡回して、気づいたら追う敵を作り、その3つを画面に描きます。描いた円と当たりの円を重ねて、敵が気づかない原因を見つけます。

① 置くもの
新しい2Dのプロジェクトで始めます。画面の大きさは既定の 1152×648 のままです。
main.tscn: ルートは Node2D(名前 Main)。Project Settings の Application → Run → Main Scene に指定する。子に Player と Enemy を置く- Player: CharacterBody2D(位置 (760, 184)。敵の真上、少し離れたところ)。Groups ドックで
playerグループに入れる。グループに入れておくと、敵はis_in_group("player")でプレイヤーかどうかを見分けられる。子に Sprite2D(icon.svg、Scale (0.25, 0.25))と CollisionShape2D(CircleShape2D、Radius 16) - Enemy: Node2D(位置 (760, 324))。子に Sprite2D(
icon.svg、Scale (0.5, 0.5))、Sight(Area2D。子に CollisionShape2D(CircleShape2D、Radius 100))、DebugDraw(Node2D)の順に並べる

見た目の大きさは Sprite2D の Scale だけで変え、ほかのノードは (1, 1) のままにします。実行する前に、Debug → Visible Collision Shapes をオンにしておきます。
② 組み立て
プレイヤーは、矢印キーで動くだけです。
# player.gd(Player に付ける)
extends CharacterBody2D
func _physics_process(_delta: float) -> void:
velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * 200.0
move_and_slide()
敵は、左右の2点を行き来し、Sight にプレイヤーが入ったら追います。Sight の Area2D は、体(CharacterBody2D など)が重なると body_entered を出します。気づく距離は detect_radius の200のつもりです。
# enemy.gd(Enemy に付ける)
class_name Enemy
extends Node2D
enum State { PATROL, CHASE }
@export var detect_radius := 200.0 # 気づく距離(のつもり)
@export var speed := 80.0
var state := State.PATROL
var target := Vector2.ZERO # いま向かっている場所(グローバル座標)
var player: Node2D = null
var patrol_points: Array[Vector2] = []
var patrol_index := 0
@onready var sight: Area2D = $Sight
func _ready() -> void:
patrol_points = [global_position + Vector2(-120, 0), global_position + Vector2(120, 0)]
target = patrol_points[patrol_index]
sight.body_entered.connect(_on_sight_body_entered)
sight.body_exited.connect(_on_sight_body_exited)
func _physics_process(delta: float) -> void:
if state == State.CHASE:
target = player.global_position
else:
target = patrol_points[patrol_index]
if global_position.distance_to(target) < 4.0:
patrol_index = (patrol_index + 1) % patrol_points.size()
global_position = global_position.move_toward(target, speed * delta) # target へ、最大 speed * delta だけ近づく
func _on_sight_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
player = body
state = State.CHASE
func _on_sight_body_exited(body: Node2D) -> void:
if body == player:
player = null
state = State.PATROL
DebugDraw は、敵の変数を読んで描くだけです。enemy.gd の1行目の class_name Enemy で、Enemy を型として書けるようにしてあります。出し入れに使う F3 は、この記事の実践で自分で決めたキーです。
# debug_draw.gd(DebugDraw に付ける)
extends Node2D
@onready var enemy: Enemy = get_parent()
func _ready() -> void:
if not OS.is_debug_build():
queue_free() # 書き出した製品版では消す
func _process(_delta: float) -> void:
queue_redraw() # 向かう先や状態の変化を、表示に反映する
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F3:
visible = not visible # F3 で出し入れする
func _draw() -> void:
var color := Color.ORANGE if enemy.state == Enemy.State.CHASE else Color.SKY_BLUE
draw_circle(Vector2.ZERO, enemy.detect_radius, color, false, 2.0) # 気づく距離(変数の値)
draw_line(Vector2.ZERO, to_local(enemy.target), color, 2.0) # いま向かっている先
draw_string(ThemeDB.fallback_font, Vector2(-40, -48), Enemy.State.keys()[enemy.state]) # 状態の名前の一覧から、いまの名前を出す
③ 動かして確かめる

Main を実行します。見るのは3つです。
まず、敵が左右の巡回点へ向かって行き来します。敵のまわりには自分で描いた円(半径200)と、それより小さい半透明の円(Sight の当たり、半径100)が出ます。プレイヤーのまわりにも、当たりの小さな円が出ます。頭上は「PATROL」で、線はいま向かっている巡回点を指しています。半透明の円が見えないなら、Visible Collision Shapes をオンにしてから実行し直します。
次に、プレイヤーの位置を見ます。プレイヤーは最初から大きい円の中にいるのに、敵は巡回を続けます。敵が左右のどこにいても、中心どうしの距離は140〜184です。プレイヤーの当たりまで入れても、小さい円には届きません。敵が気づく距離は、描いた200ではなく、当たりの100でした。
ゲームの画面をクリックして↓キーで近づき、プレイヤーの当たりが小さい円に重なると、「CHASE」に変わります。円と線がオレンジになり、線がプレイヤーへ伸びます。横へ逃げ続けると、プレイヤーのほうが速いので振り切れて、「PATROL」に戻ります。
仕上げに、enemy.gd の _ready() の先頭へ1行足して、実行し直します。
($Sight/CollisionShape2D.shape as CircleShape2D).radius = detect_radius # 当たりの円を、描いている円に合わせる
描いた円と当たりの円の中心と半径がそろい、実行した直後に「CHASE」になります。敵を何体も置くなら、この1行の前に形を複製しておきます(理由は「うまくいかないときは」の2行目)。プレイヤーにも半径16の当たりがあるので、気づく境目は中心どうしの距離で200+16=216です。最初の距離は140なので、もう範囲の中です。
ポイントは2つです。
- 描くのは、判定が読んでいる値: 描いた円は
detect_radiusを読み、判定は Sight の円を読んでいました。別々の値を見ていると、描いた円と実際の判定が食い違います。直した1行で、起動したときに当たりの円がdetect_radiusに合わせられ、描く値と判定の値が同じになりました - エンジンの表示と重ねる: 自分で描いた円だけを見ていると、ずれに気づけません。Visible Collision Shapes の当たりと重ねたので、2つの円の差が見えました。見える角度や壁の判定を足すなら、視界判定入門 が続きです
うまくいかないときは
| 症状 | 原因 | 直し方 |
|---|---|---|
| 円が塗りつぶされて、重なったキャラクターが隠れる | draw_circle() の既定 は塗りつぶし | 4つ目の引数を false、5つ目に線の太さ(2.0 など)を渡す |
| 敵を複製して1体の半径を変えたら、全員の反応する距離が変わった | 複製した敵は、同じ CircleShape2D を共有している(形のデータは、ノードを複製しても同じ1つを指したまま) | 半径を書き換える前に、shape を shape.duplicate() した物へ差し替える |
draw_string() でエラーになる、文字の位置がずれる | 最初の引数にフォントが要る。位置は文字の左端のベースライン(文字の下側の線) | フォントは ThemeDB.fallback_font(テーマが用意している予備のフォント)を渡す。頭上に出すなら、Y を負の値にする |
| F3 で消しても、半透明の円は残る | 半透明の円は Visible Collision Shapes が描いている | Debug メニューでオフにして、実行し直す |
おまけ:先に知っておくと良いこと
- 編集中にも円を見たい: スクリプトの先頭に
@tool(エディタの中でもそのスクリプトを動かす印)を付けると、エディタの中でも_draw()を動かせます。この記事の DebugDraw は敵のスクリプトの値を読むので、@toolを1行足すだけでは動きません。組み方は エディタ拡張入門 にあります - 境界で状態がちらつくとき: 気づく円と見失う円を分け、見失う円を少し大きくします。2つの円を描けば、あいだの幅が見えます
- ナビゲーションの経路を見たいとき: NavigationAgent2D は
debug_enabledをオンにすると、計算した経路を線で描きます。使い方は NavigationAgent2D入門 にあります
まとめ
_draw()は頼んだときだけ: 描いた絵は残る。線の先や文字の値が変わるならqueue_redraw()- 座標は描いているノードから見た位置: 相手の位置は
to_local()で渡す - エンジンが知っている形は Debug メニュー: 変数・向かう先・状態は自分で描く
- 描画用のノードは子に分ける: スプライトの上に描け、出し入れが1か所で済む
- 描くのは判定が読んでいる値: エンジンの表示と重ねて、ずれを見る
まずは気づく距離の円から描いてみてください。あなたの敵AIで、画面に出して確かめたい値はどれですか。