エネミーAI を実装する際、最も基本的かつ重要なのが「経路探索」です。Godot 4 には AStar2D という A* アルゴリズムの実装が標準で組み込まれており、自前で書かずに済みます。
この記事では、AStar2D を使ったタイルマップ上のエネミー経路探索を、コード例とともに実装していきます。
なぜ A* が必要か
シンプルなエネミー AI なら、global_position.direction_to(player.global_position) でプレイヤー方向に移動するだけで動きます。しかし、これは壁を貫通します。
# 壁を考慮しない単純な追跡
func _physics_process(delta: float) -> void:
var direction = global_position.direction_to(player.global_position)
velocity = direction * speed
move_and_slide()
タイルマップに壁がある場合、エネミーが壁にハマる、ジグザグに引っかかる、といった問題が頻発します。これを解決するのが A* 経路探索です。
AStar2D の基本構造
AStar2D はノードベースのグラフを保持します。各ノード(点)に ID を割り当て、ノード間にエッジ(接続)を追加していきます。
extends Node2D
var astar: AStar2D
func _ready() -> void:
astar = AStar2D.new()
# ノード追加: ID, 座標
astar.add_point(0, Vector2(0, 0))
astar.add_point(1, Vector2(100, 0))
astar.add_point(2, Vector2(100, 100))
# ノード接続
astar.connect_points(0, 1)
astar.connect_points(1, 2)
# 経路計算
var path: PackedVector2Array = astar.get_point_path(0, 2)
print(path) # [(0, 0), (100, 0), (100, 100)]
このシンプルな例では、ID 0 から ID 2 への最短経路を計算しています。get_point_path は経由する全座標を返します。
タイルマップとの統合
実際のゲームでは、TileMapLayer のセル座標を AStar2D のノードに変換します。
extends Node2D
@export var tilemap: TileMapLayer
var astar: AStar2D
func _ready() -> void:
astar = AStar2D.new()
_build_navigation_graph()
func _build_navigation_graph() -> void:
var used_cells = tilemap.get_used_cells()
# 1. 通行可能なセルをノードとして追加
for cell: Vector2i in used_cells:
if _is_walkable(cell):
var id = _cell_to_id(cell)
astar.add_point(id, tilemap.map_to_local(cell))
# 2. 隣接セル同士を接続
for cell: Vector2i in used_cells:
if not _is_walkable(cell):
continue
var id = _cell_to_id(cell)
for neighbor: Vector2i in _get_neighbors(cell):
if _is_walkable(neighbor):
var neighbor_id = _cell_to_id(neighbor)
astar.connect_points(id, neighbor_id)
func _cell_to_id(cell: Vector2i) -> int:
# 一意な ID 生成。マップサイズが 1024x1024 以下なら衝突しない
return cell.x * 1024 + cell.y
func _is_walkable(cell: Vector2i) -> bool:
var data = tilemap.get_cell_tile_data(cell)
if data == null:
return false
return data.get_custom_data("walkable") == true
func _get_neighbors(cell: Vector2i) -> Array:
return [
cell + Vector2i(1, 0),
cell + Vector2i(-1, 0),
cell + Vector2i(0, 1),
cell + Vector2i(0, -1),
]
このコードのポイント:
-
_cell_to_id: タイル座標を一意な整数 ID に変換します。x * MAX_WIDTH + yのパターンが定番です。 -
_is_walkable: TileSet のカスタムデータレイヤーでwalkableプロパティを設定しておきます。エディタで TileSet を開き、Custom Data Layers にwalkable(bool) を追加し、各タイルに値をセットします。 -
_get_neighbors: 4方向だけ。8方向(斜め含む)にすると経路が滑らかになりますが、コストも上がります。
エネミーから呼び出す
エネミーノードでは、毎フレーム経路を計算するのは無駄なので、一定間隔で更新します。
extends CharacterBody2D
@export var navigation_node: Node2D # 上で作った AStar2D を持つノード
@export var speed: float = 100.0
var path: PackedVector2Array = []
var path_index: int = 0
var path_update_timer: Timer
func _ready() -> void:
path_update_timer = Timer.new()
path_update_timer.wait_time = 0.5 # 0.5秒ごとに経路再計算
path_update_timer.timeout.connect(_update_path)
add_child(path_update_timer)
path_update_timer.start()
func _update_path() -> void:
var player = get_tree().get_first_node_in_group("player")
if not player:
return
path = navigation_node.get_path_to(global_position, player.global_position)
path_index = 0
func _physics_process(_delta: float) -> void:
if path.is_empty() or path_index >= path.size():
velocity = Vector2.ZERO
move_and_slide()
return
var target = path[path_index]
var direction = global_position.direction_to(target)
velocity = direction * speed
if global_position.distance_to(target) < 5.0:
path_index += 1
move_and_slide()
毎フレームではなく 0.5 秒間隔で _update_path を呼ぶことで、CPU 使用率を下げています。プレイヤーの移動が早くてもズレすぎなければ問題ありません。
パフォーマンスの落とし穴
A* は経路の長さに比例した計算量が必要です。マップが大きくエネミーが多いと、これが処理時間の大部分を占めることがあります。
1. グラフ構築は1回だけ
_build_navigation_graph は _ready() で1回だけ呼ぶようにしてください。タイルが動的に変わらない限り、毎フレーム再構築する必要はありません。
2. 経路再計算の頻度
エネミーが多い場合、全員が毎フレーム経路を再計算すると重くなります。0.3〜1.0秒の間隔がバランスがいいです。プレイヤーが動いた距離が一定以上の場合のみ再計算する、という最適化もあります。
var last_player_position: Vector2
func _update_path() -> void:
var player = get_tree().get_first_node_in_group("player")
if not player:
return
if global_position.distance_to(last_player_position) < 50.0:
return # プレイヤーがあまり動いていなければスキップ
last_player_position = player.global_position
path = navigation_node.get_path_to(global_position, player.global_position)
path_index = 0
3. AStarGrid2D という選択肢
タイルマップが完全にグリッド状(タイルが格子配置で、サイズがすべて同じ)なら、Godot 4.2 で追加された AStarGrid2D のほうが簡単です。AStar2D のように手動でノードを追加せず、グリッドのサイズを指定するだけで自動的にグラフが構築されます。
var grid_astar: AStarGrid2D
func _ready() -> void:
grid_astar = AStarGrid2D.new()
grid_astar.region = Rect2i(0, 0, 100, 100)
grid_astar.cell_size = Vector2(32, 32)
grid_astar.update()
# 壁のセルを通行不可にする
for cell in walls:
grid_astar.set_point_solid(cell, true)
ピクセルベースで経路を返してくれるので、タイル座標とワールド座標の変換も内部でやってくれます。
AI コーディングツールでの実装
A* の実装は定番のパターンですが、Godot 4 の API が独特なので、汎用 LLM だと Godot 3 の API(get_simple_path など、現在は廃止されている)を生成しがちです。
Ziva のような Godot 専用のエージェントは、現在インストールされている Godot のクラスデータベースを参照しながらコードを生成するため、AStar2D と AStarGrid2D の使い分けや、AStarGrid2D.set_point_solid のような新しい API も正しく出力します。
汎用 LLM を使う場合は、プロンプトに「Godot 4.4 の AStar2D を使って」と明示すると、Godot 3 のコードが出る確率を下げられます。
まとめ
AStar2D を使ったエネミー経路探索の流れ:
- タイルマップから通行可能なセルを抽出して
AStar2Dのノードとして追加 - 隣接ノード同士を接続
- エネミーの
_physics_processでget_point_pathを呼んで経路を取得 - 0.5 秒ごとに経路を再計算(毎フレームは重い)
タイルが完全グリッドなら AStarGrid2D の方がコードが短くなります。プロジェクトの規模に合わせて使い分けてください。
ナビゲーションシステム (NavigationServer2D) を使う方法もありますが、設定が複雑なため、シンプルなタイルベースのゲームでは AStar2D で十分です。