【Godot】AStar2Dを使用した経路探索の実装方法

この記事では、AStar2Dを使用した経路探索の実装方法を紹介します。

AStar2Dを使用するための前提知識

ノードについて

まず実装方法の紹介の前にAStar2Dを使うために必要な前提知識を紹介します。AStar2Dでは「ノード」という単位で経路探索を行います。

ここで注意しておくべき点は2つです。

  1. ノードには「番号」が割り振られる
  2. 経路探索ではノード同士の「接続情報」が必要となる

例えば、1番のノードは「2番」「3番」と接続しており、6番のノードが接続しているのは「4番」のみとなります。

そのため、それぞれの移動コストが「1」とした場合、1から6への最短距離は「1 -> 3 -> 4 -> 6」となります。

座標系の扱い

AStar2Dでは、以下の3つの座標系を使用します。

  • 1. ワールド座標系
  • 2. グリッド座標系
  • 3. インデックス座標系

1. ワールド座標系

ワールド座標系とは、2Dゲームであればスクリーンのピクセル単位の座標となります。

ワールド座標系

タイル1つのサイズが 64×64 ピクセルとした場合、Godotくんの座標は

  • x = 64 * 2 = 128
  • y = 64 * 1 = 64

(x, y) = (128, 64) となります。

ひとまずはワールド座標系とは、実際にゲーム画面に表示されるときに使用する座標と考えて問題ありません。

2. グリッド座標系

次にグリッド座標系です。タイル1つを最小単位とした座標系です。

例えば以下の画像での Godotくんの座標は (x, y) = (2, 1) となります。

グリッド座標系

3. インデックス座標系

これは左上のタイルから順番に整数値を割り振ったものです。

例えば以下の画像の場合、Godotくんは「18番」に存在することになります。

インデックス座標系

AStar2Dはこの「インデックス番号をノード番号」として扱い、経路探索を行います

それぞれは相互に変換が可能

それぞれの座標系は相互に変換が可能です

今回使用する素材では、タイルサイズは「64px」でマップの横の数が「16」なので、このように相互に変換ができます。ただし「ワールド座標系→グリッド座標系」への変換ではタイルサイズ以下のワールド座標情報は失われるので、場合によっては変換前の情報は保持しておいたほうが良い場合があります。

AStar2Dを使用した経路探索の実装方法

障害物画像を追加する

今回使用する障害物画像としては以下のものを使用します。

obstacle.png

ちなみに「obstacle」とは「障害物」の意味となります。

この画像をプロジェクトに追加します。

MainシーンとTileMapノードの作成

まずはMainシーンとTileMapノードを作成します。

タイルマップリソースの追加

次にタイルマップリソースを作成します。ファイルシステムで右クリックして「新規リソース」を選びリソースを作成します。

リソースの中から「TileSet」を選び、タイルマップリソースを作成します。

リソース名は「tileset.tres」として保存します。

そうしたら「tileset.tres」をダブルクリックして「タイルセット」をエディタの下部に表示したあと、「obstacle.png」をドラッグ&ドロップしてタイルセット画像に登録します。

次の「新しいシングルタイル」をクリックし、スナップアイコンをクリックします。

タイルとして使用する領域をドラッグで選択します。

TileMapノードの設定をする

タイルマップリソースが作成できたので、TileMapノードを選択してタイルマップリソースを割り当てます。

インスペクターの「TileMap > Tile Set > [空]」をクリックして「読み込み」を選択します。

先ほど作成したタイルセットリソース(tileset.tres)を読み込みます。

これでタイルマップの設定ができました。

キャンバスにタイルを配置できるようになります。

タイルはひとまず以下のように配置しました。

必ずしもこのようにする必要はなく、移動できない空間を作らなければ、おおよその配置で問題ありません。

ここまでのプロジェクトファイル

ここまでの作業のプロジェクトファイルを添付しておきます。

もし正常に動作しない場合はこちらから確認できます。

Godotくんと Line2D を作成・配置する

続けて「icon.png」をドラッグ&ドロップしてGodotくんを配置し、ノード名を「Godot」に変更します。さらに移動経路を表示するための「Line2D」を追加しておきます。

Godotくんは (x, y) = (160, 96) などにするとグリッドに収まる位置となります。

AStar2D を使うスクリプトを作成する

これでようやく準備ができました。TileMapノードを選択して、TileMap.gd スクリプトを作成し、以下のように記述します。

extends TileMap

# 障害物のマップチップID
const CHIP_OBSTACLE = 0

# タイルマップのサイズ
export(Vector2) var map_size = Vector2(16, 9) # 16x9

# 経路の開始地点と終了地点
var path_start_position = Vector2() setget _set_path_start_position
var path_end_position = Vector2() setget _set_path_end_position


# AStarノードを生成
onready var astar_node = AStar2D.new()

# 障害物の位置リスト
onready var obstacles:PoolVector2Array = get_used_cells_by_id(CHIP_OBSTACLE)

# グリッドの半分のサイズ (左上を中心に移動させるために必要)
onready var _HALF_CELL_SIZE = cell_size / 2

func _ready() -> void:
	# 移動可能な位置のリストを作成する
	var walkable_cells_list = _get_walkable_cells(obstacles)
	
	# 移動可能な位置をAStarに登録する
	for p in walkable_cells_list:
		# 位置をインデックスに変換する
		var index = _point_to_index(p)
		# 登録
		astar_node.add_point(index, p)
	
	# AStarにノード同士の接続情報を登録する
	_astar_connect_walkable_cells(walkable_cells_list)
	
# 移動可能な位置のリストを作成する
func _get_walkable_cells(_obstacles = []) -> PoolVector2Array:
	
	# 結果
	var result := PoolVector2Array()
	
	# マップのすべての要素をチェックする
	for j in range(map_size.y):
		for i in range(map_size.x):
			
			# ポイントを作成する
			var p = Vector2(i, j)
			if p in _obstacles:
				continue # 障害物は含めない
			
			# 移動できるので結果に登録する
			result.append(p)
			
	return result

# AStarにノード同士の接続情報を登録する
func _astar_connect_walkable_cells(points_array:PoolVector2Array) -> void:
	for p in points_array:
		# インデックスに変換する
		var index = _point_to_index(p)
		
		# 上下左右に接続する
		var points_ralative := PoolVector2Array([
			Vector2(p.x + 1, p.y), # 右
			Vector2(p.x - 1, p.y), # 左
			Vector2(p.x, p.y + 1), # 下
			Vector2(p.x, p.y - 1)]) # 上
		
		# 上下左右を調べる
		for p_relative in points_ralative:
			# インデックスに変換
			var relative_index = _point_to_index(p_relative)
			
			if is_outside_map_bounds(p_relative):
				continue # 領域外なので接続できない
			if not astar_node.has_point(relative_index):
				continue # 移動不可なので接続できない
			
			# インデックス同士を接続する
			# 第3引数がfalseなので、index -> relative_index への一方通行を許可
			astar_node.connect_points(index, relative_index, false)

# AStarにノード同士の接続情報を登録する (斜め移動を許可する)
func _astar_connect_walkable_cells_diagonal(points_array:PoolVector2Array) -> void:
	for p in points_array:
		var index = _point_to_index(p)
		# 3x3の9方向探索
		for local_y in range(3):
			for local_x in range(3):
				var point_relative = Vector2(p.x + local_x - 1, p.y + local_y - 1)
				var relative_index = _point_to_index(point_relative)

				if point_relative == p or is_outside_map_bounds(point_relative):
					continue # 領域外
				if not astar_node.has_point(relative_index):
					continue # 障害物
				astar_node.connect_points(index, relative_index, true)

# 位置をインデックスに変換する
func _point_to_index(p:Vector2) -> int:
	return p.x + (p.y * map_size.x)

# ワールド座標をグリッド座標系に変換する
func _world_to_point(v:Vector2) -> Vector2:
	return Vector2(
		int(v.x/cell_size.x),
		int(v.y/cell_size.y))

# ワールド座標をインデックスに変換する
func _world_to_index(v:Vector2) -> int:
	var p := _world_to_point(v)	
	return _point_to_index(p)

# インデックスをワールド座標に変換する
func _index_to_world_position(index:int) -> Vector2:
	var i = index%map_size.x
	var j = int(index/map_size.x)
	return Vector2(
		i * cell_size.x,
		j * cell_size.y)

# 指定の位置がマップの領域外かどうか
func is_outside_map_bounds(p:Vector2) -> bool:
	if p.x < 0 or p.y < 0 or p.x >= map_size.x or p.y >= map_size.y:
		return true # 領域外
	
	# 有効なマップの範囲内
	return false

# AStarによる経路探索を実行する
func recalculate_path() -> PoolVector2Array:
	var start_index = _point_to_index(path_start_position)
	var end_index = _point_to_index(path_end_position)
	
	var point_list = astar_node.get_point_path(start_index, end_index)
	
	# ワールド座標に変換する
	for i in range(point_list.size()):
		point_list[i].x *= cell_size.x	
		point_list[i].y *= cell_size.y
		
		# 左上座標を中心に移動させる
		point_list[i].x += _HALF_CELL_SIZE.x
		point_list[i].y += _HALF_CELL_SIZE.y
	
	return point_list

# -------------------------------------------
# setter
# -------------------------------------------
# 開始地点の設定
func _set_path_start_position(v:Vector2):
	
	# グリッド座標系に変換する
	var p := _world_to_point(v)
	
	if p in obstacles:
		return # 障害物の位置には設定できない
	if is_outside_map_bounds(p):
		return # マップ領域外には設定できない
	
	path_start_position = p

# 終了地点の設定
func _set_path_end_position(v:Vector2):

	# グリッド座標系に変換する
	var p := _world_to_point(v)
	
	if p in obstacles:
		return # 障害物の位置には設定できない
	if is_outside_map_bounds(p):
		return # マップ領域外には設定できない
	
	path_end_position = p

かなり長いコードとなっており、読み解くのが大変かもしれませんので順番に説明します。

大まかな流れ

このコードの大まかな流れとしては以下のとおりです。

_ready関数内の処理 (初期化処理。AStar2Dにノードを登録する)
  1. 移動可能な座標(グリッド座標)のリストを_get_walkable_cells()を使用して作成する
  2. 移動座標のリストをAStar2D に「インデックス番号」として登録する
  3. _astar_connect_walkable_cells() を呼び出してノード同士の接続情報を AStar2Dに登録する
開始座標と終端座標を設定する (path_start_position / path_end_position)

開始座標をpath_start_position、終端座標(ゴール地点)を path_end_positionに登録します

それぞれの変数(プロパティ)は「ワールド座標系」から「グリッド座標系」に変換して値を保持するようにしています。

経路探索の実行 (recalculate_path)

recalculate_path() で経路探索を実行します。

AStar2D::get_point_path() により得られる値は「グリッド座標系」なので、「ワールド座標系」に変換して座標のリストを返します。

_get_walkable_cells() についての説明

まずは移動可能な位置のリストを作成する _get_walkable_cells() の説明です。

このタイルマップで使用するマップサイズ(グリッド座標系における単位)は “map_size” 変数に含まれているので、それを使用します。

# タイルマップのサイズ
export(Vector2) var map_size = Vector2(16, 9) # 16x9

なおタイルマップの有効な範囲を設定する項目はないため、このようにスクリプトで定義する必要があるようです。

タイルマップの幅と高さの2重ループでグリッド座標のリストを作成します。

	# マップのすべての要素をチェックする
	for j in range(map_size.y):
		for i in range(map_size.x):
			
			# ポイントを作成する
			var p = Vector2(i, j)
			if p in _obstacles:
				continue # 障害物は含めない
			
			# 移動できるので結果に登録する
			result.append(p)

なお、”_obstacles” は障害物となるタイル情報です。

# 障害物のマップチップID
const CHIP_OBSTACLE = 0

# 障害物の位置リスト
onready var obstacles:PoolVector2Array = get_used_cells_by_id(CHIP_OBSTACLE)

“TileMap::get_used_cells_by_id()” はタイルIDを元に対象となるタイルのグリッド座標のリストを返す関数となります。

なお、タイルIDはタイルマップエディタのここに表示される数字です。

ノード番号をAStar2Dに登録する

作成した移動可能な位置情報をAStar2Dに登録しているのが以下のコードです。

	# 移動可能な位置をAStarに登録する
	for p in walkable_cells_list:
		# 位置をインデックスに変換する
		var index = _point_to_index(p)
		# 登録
		astar_node.add_point(index, p)

この記事の冒頭で説明したとおり、AStar2Dはノード番号で管理を行うので、グリッド座標系をインデックス座標系に変換して登録しています。

ノード同士の接続情報をAStar2Dに登録する

ノード同士の接続情報を登録しているのが、_astar_connect_walkable_cells() となります。

# AStarにノード同士の接続情報を登録する
func _astar_connect_walkable_cells(points_array:PoolVector2Array) -> void:
	for p in points_array:
		# インデックスに変換する
		var index = _point_to_index(p)
		
		# 上下左右に接続する
		var points_ralative := PoolVector2Array([
			Vector2(p.x + 1, p.y), # 右
			Vector2(p.x - 1, p.y), # 左
			Vector2(p.x, p.y + 1), # 下
			Vector2(p.x, p.y - 1)]) # 上
		
		# 上下左右を調べる
		for p_relative in points_ralative:
			# インデックスに変換
			var relative_index = _point_to_index(p_relative)
			
			if is_outside_map_bounds(p_relative):
				continue # 領域外なので接続できない
			if not astar_node.has_point(relative_index):
				continue # 移動不可なので接続できない
			
			# インデックス同士を接続する
			# 第3引数がfalseなので、index -> relative_index への一方通行を許可
			astar_node.connect_points(index, relative_index, false)

処理の流れとしては、移動可能なノードをループで回して、そのノードから上下左右に接続可能かどうかを調べています。

そして接続できれば、AStar2D::connect_to_points() で、接続元のインデックス番号、接続先のインデックス番号を登録します。

なお第3引数は、一方通行とするかどうかのフラグで、今回は一方通行が存在しないので「true」でも良いですが、もととなるサンプルコードが「false」だったのでひとまずこの値としました。

開始座標と終端座標(ゴール)の設定

開始座標と終端座標の設定は以下の関数で行っています。

# -------------------------------------------
# setter
# -------------------------------------------
# 開始地点の設定
func _set_path_start_position(v:Vector2):
	
	# グリッド座標系に変換する
	var p := _world_to_point(v)
	
	if p in obstacles:
		return # 障害物の位置には設定できない
	if is_outside_map_bounds(p):
		return # マップ領域外には設定できない
	
	path_start_position = p

# 終了地点の設定
func _set_path_end_position(v:Vector2):

	# グリッド座標系に変換する
	var p := _world_to_point(v)
	
	if p in obstacles:
		return # 障害物の位置には設定できない
	if is_outside_map_bounds(p):
		return # マップ領域外には設定できない
	
	path_end_position = p

座標系の扱いですが、引数を「ワールド座標系」とし、内部で保持するときには「グリッド座標系」にしました。理由としては、障害物や領域外の判定(is_outside_map_bounds関数) が「グリッド座標系」で判定するためとなります。

AStarによる経路探索の実行

AStarによる経路探索の実行は recalculate_path() で行っています。

# AStarによる経路探索を実行する
func recalculate_path() -> PoolVector2Array:
	var start_index = _point_to_index(path_start_position)
	var end_index = _point_to_index(path_end_position)
	
	var point_list = astar_node.get_point_path(start_index, end_index)
	
	# ワールド座標に変換する
	for i in range(point_list.size()):
		point_list[i].x *= cell_size.x	
		point_list[i].y *= cell_size.y
		
		# 左上座標を中心に移動させる
		point_list[i].x += _HALF_CELL_SIZE.x
		point_list[i].y += _HALF_CELL_SIZE.y
	
	return point_list

このスクリプト全体が、「ワールド座標系で受け取る」という仕様になっているので、「ワールド座標系の移動リストを返す」としました。

なお、ワールド座標系を返す場合「左上」を原点とするのか「中心」を原点とするのかで処理が変わると思います。

今回はタイルの中心を原点としたワールド座標のリストを返すようにしています。



※ここまでで TileMap.gd の説明おわり



経路探索を呼び出すスクリプトの作成

経路探索は実装できたので、これを使用するスクリプトを書きます。

Mainノードを選択し、Main.gd スクリプトをアタッチします。

extends Node2D

# タイルマップ
onready var tile_map := $TileMap

# 経路を視覚的に表示する線
onready var line_2d := $Line2D

# キャラクター
onready var godot := $Godot

# 左マウスクリックを処理する
func _unhandled_input(event: InputEvent) -> void:
	if not event is InputEventMouseButton:
		return # マウスイベントでない
	if event.button_index != BUTTON_LEFT or not event.pressed:
		return # 左クリックでない
	
	# キャラクターから左クリックした位置までの経路を求める
	tile_map.path_start_position = godot.position
	tile_map.path_end_position = event.position
	var new_path = tile_map.recalculate_path()
	
	# 経路を線で視覚的に表示する
	line_2d.points = new_path

では実行して動作を確認します。クリックした位置に移動経路が表示されます。

完成プロジェクトファイル

この記事で作成したプロジェクトファイルを添付しておきます。

参考

今回の記事を書くに当たって、以下で公開されているコードを参考にしました。

経路に沿って移動するサンプル

経路探索の処理に違いはありませんが、クリックした位置にGodotくんが移動するサンプルを作成しておきました。

以下からダウンロードできます。