0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Godot 4でA*探索アルゴリズムを使ったエネミーAI: AStar2D実装ガイド

0
Posted at

エネミー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 のクラスデータベースを参照しながらコードを生成するため、AStar2DAStarGrid2D の使い分けや、AStarGrid2D.set_point_solid のような新しい API も正しく出力します。

汎用 LLM を使う場合は、プロンプトに「Godot 4.4 の AStar2D を使って」と明示すると、Godot 3 のコードが出る確率を下げられます。

まとめ

AStar2D を使ったエネミー経路探索の流れ:

  1. タイルマップから通行可能なセルを抽出して AStar2D のノードとして追加
  2. 隣接ノード同士を接続
  3. エネミーの _physics_processget_point_path を呼んで経路を取得
  4. 0.5 秒ごとに経路を再計算(毎フレームは重い)

タイルが完全グリッドなら AStarGrid2D の方がコードが短くなります。プロジェクトの規模に合わせて使い分けてください。

ナビゲーションシステム (NavigationServer2D) を使う方法もありますが、設定が複雑なため、シンプルなタイルベースのゲームでは AStar2D で十分です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?