この記事では、AStar というアルゴリズムを利用して、2D ゲームにおけるグリッドベースの経路探索の実装方法について紹介する。なお、グリッドベースではない 2D 経路探索については、「Godot で作る 2D 経路探索 」の記事で紹介しているので、作りたいゲームに併せて記事を選んでいただければ幸いだ。

このチュートリアルで最後にできあがるプロジェクトのファイルは GitHubリポジトリ に置いている。.zipファイルをダウンロードして展開したあと、「project.godot」ファイルを Godot Engine でインポートしていただければ、直接プロジェクトを確認していただくことも可能だ。

Environment
Godot のバージョン: 3.5
コンピュータのOS: macOS 11.6.5

Basic Articles
以下の記事もお役立てください。
Godot をダウンロードする
Godot のプロジェクトマネージャー
Godot の言語設定



AStar について

AStar という探索アルゴリズムを使って、グリッドベースの経路探索を実装する。現在地から目的地まで自動的にオブジェクトをグリッドに沿って移動させたい時に便利だ。例えば、盤面上でピースを移動させるようなパズルゲームや、盤面上で敵、味方それぞれのキャラクターを移動させて戦わせるような戦略シミュレーションゲームなどに最適な手法である。

AStar とは

エースターと読み、A* とも表記する。スタート地点からゴール地点まで障害物を回避しつつ最短の経路を探索するアルゴリズムである。ゲーム開発で多く採用されている。Godot エンジンの場合、一からアルゴリズムをコーディングしなくても済むように、AStar のクラスが初めから用意されている。今回のチュートリアルでもそれを利用する。

Wikipedia - A*
Godot Docs - AStar2D



プロジェクト作成

まずはプロジェクトを新規作成する。今回、プロジェクトの名前は「2D Grid Based Path Finding」としておく。

プロジェクト設定

「プロジェクト」メニュー>「プロジェクト設定」にて以下の設定を行う。

  • 「一般」タブ
    • 「Display」>「Window」
      • 「Size」セクション
        • Width: 1024
        • Height: 576
      • 「Stretch」セクション
        • Mode: 2d
        • Aspect: keep
  • 「インプットマップ」タブ
    1. アクションに「move_to」を追加
    2. 「move_to」の操作に「マウス左ボタン」を割り当てる。
      Inputmap - action - shake

アセットのインポート

今回はKENNEYのサイトから Board Game Icons というアセットパックを利用させていただいた。この素晴らしすぎる無料の素材に感謝せずにはいられない。

ダウンロードしたら「/kenney_boardgameicons/PNG/Default (64px)」フォルダ内の以下のファイルを Godot エディタのファイルシステムドックへドラッグ&ドロップしてプロジェクトにインポートする。

  • character.png
  • d3.png
  • structure_wall.png


Game シーンを作る

まずはプロジェクトのメインとなる「Game」シーンを作る。ルートノードに「Node2D」を選択し、それに必要なノードを追加、名前を変更して、以下のようなシーンツリーを作成する。

  • Game (Node2D)
    • Board (TileMap)
      • Obstacles (Node2D) *あとでこのノードに子ノードを追加する予定
    • Line (Line2D)
    • Player (Sprite)
    • AStarVisualizer (Control)

Game scene tree


ノードを編集する

Board (TileMap) ノード

ゲームのプレイヤーの駒が移動する盤面として、「Board」ノードを編集する。

  1. 「Board」ノードを選択し、インスペクターにて「Tile Set」プロパティに「新規 TileSet」リソースを適用する。
    TileMap - add Tile Set resource to Tile Set property
  2. インスペクターで適用した「TileSet」をクリックし、タイルセットパネルを開く。
  3. 先にインポートしておいた「d3.png」をファイルシステムドックからタイルセットパネルのサイドバーにドラッグ&ドロップして、タイルセットのテクスチャとして追加する。
  4. 追加したテクスチャを選択し、「New Single Tile」として、その「領域」を指定して追加する。
    TileSet Pannel
  5. そのままインスペクターにて、「Modulate」プロパティでタイルを好みの色に変更する。
    TileSet Pannel
  6. シーンドックで「Board」ノードを選択し、以下のポイントに注意しながら、2D ワークスペース上でタイルマップを作成する。
    • ゲームのウインドウサイズに収まる範囲内でタイルマップを作成する。
    • 配置するタイルはプレイヤーノードが移動可能な領域として使用する。
    • あとでプレイヤーノードを一番左上のグリッド座標 (0, 0)に配置するため、必ずその位置にタイルを配置し、そこからプレイヤーノードが移動できるようにタイルを適当につなげて配置する。
      editting Board node

Obstacles (Node2D) ノード

「Obstacles」ノードは、タイルマップ上に配置する複数の障害物ノードをまとめるための入れ物(親ノード)として使用する。したがって、このノード自体は編集不要だ。

Obstacle シーンを作成する

タイルマップ上に配置する障害物のシーンを作成する。

  1. ルートノードに「Sprite」を選択し、名前を「Obstacle」に変更する。
  2. インスペクターにて「Texture」プロパティに、ファイルシステムドックから「structure_wall.png」をドラッグ&ドロップしてリソースを適用する。
    Obstacle - Texture
  3. 「Offset」>「Centered」プロパティをオフにする。
    Obstacle - Offset - Centered
  4. 「Modulate」プロパティで、先に用意した「Board」ノードのタイルの上に重ねた時に視認しやすい色に変更する。
    Obstacle - Modulate

Obstacles ノードに Obstacle シーンのインスタンスを追加する

「Game」シーンに戻り、「Obstacles」ノードに作成した「Obstacle」シーンのインスタンスノードを10個追加し、タイルマップ上の適当な位置に配置する。ただし、グリッド座標 (0, 0) にはプレイヤーの駒を配置するのでそこには配置しないように注意する。
Obstacles - 2D Workspace


Player (Sprite) ノード

盤面上を AStar による経路探索で移動させる駒としてこのノードを使用する。

  1. インスペクターにて、「Texture」プロパティにファイルシステムドックから「character.png」をドラッグ&ドロップしてリソースを適用する。
    Player - Texture
  2. 「Offset」>「Centered」プロパティをオフにする。
    Player - Offset - Centered
  3. 2D ワークスペースにて、プレイヤーの初期位置である座標 (0, 0) に配置されていることを確認する。
    Player - 2D Workspace


スクリプトで制御する

Board ノードにスクリプトをアタッチする

AStar アルゴリズムによる経路探索は、大まかには以下のような流れになる。

  1. タイルマップに配置したタイルの位置を取得する
  2. タイルの位置を AStar の点として追加する
  3. AStar のそれぞれの点に対して、上下左右で隣接している点に接続する
  4. 障害物の位置を取得する
  5. 障害物の位置に相当する AStar の点を無効化する。
  6. AStar の有効な点をつなぐ線の中から経路探索する。

「Board」ノードにスクリプトをアタッチして、コードを以下のように編集する。

extends TileMap

# Player の移動するパスの点を格納する配列
var path: Array = []
# Board(TileMap)でタイルが配置されているセルの配列
var cells = get_used_cells()

# Obstacles ノードの参照
onready var obstacles = $Obstacles
# AStar2D クラスのインスタンス
onready var astar = AStar2D.new()
# Board(TileMap) のセルの半分のサイズ
onready var half_cell_size = cell_size / 2


func _ready():
    # AStar の点を追加するメソッドを呼び出す
	add_points()
    # AStar の点をつなぐメソッドを呼び出す
	connect_points()
    # AStar の点を無効化するメソッドを呼び出す
    # 引数は Obstaclesノードの子ノードの位置を配列で返すメソッド
	disable_points(get_obstacles())


# AStar の点を追加するメソッド
func add_points():
    # タイルマップ上のタイルが配置されたセルでループ処理
	for cell in cells:
        # セルのIDを生成して AStar の点として追加する
		astar.add_point(id(cell), cell)


# AStar の点をつなぐメソッド
func connect_points():
    # タイルマップ上のタイルが配置されたセルでループ処理
	for cell in cells:
        # そのセルが AStar の点に含まれていたら
		if astar.has_point(id(cell)):
            # 隣り合う四方向の方向ベクトルを配列にする
			var neighbors = [
				Vector2.RIGHT,
				Vector2.LEFT,
				Vector2.DOWN,
				Vector2.UP
			]
            # それぞれの方向ベクトルに対してループ処理
			for neighbor in neighbors:
                # 隣接するセルを定義
				var next_cell = cell + neighbor
                # 隣接するセルにタイルが配置されている場合
				if cells.has(next_cell):
                    # AStar のもとのセル上の点と隣接するセル上の点をつなぐ
					astar.connect_points(id(cell), id(next_cell), false)


# Obstaclesノードの子ノードの位置を配列で返すメソッド
func get_obstacles() -> Array:
    # 障害物が配置されているセルのグリッド座標を入れる配列
	var obstacle_cells = []
    # Obstacles の全ての子ノード(Obstacle インスタンス)でループ処理
	for child in obstacles.get_children():
        # 用意した配列に障害物のグリッド座標を追加する
		obstacle_cells.append(world_to_map(child.global_position))
    # 戻り値として配列を返す
	return obstacle_cells


# AStar の点を無効化するメソッド
# 引数にはセルのグリッド座標を要素にもつ配列を渡す
func disable_points(target_cells):
    # 引数の配列の要素(セルのグリッド座標)でループ処理
	for cell in target_cells:
        # 該当のセルに相当する AStar の点を無効化する
		astar.set_point_disabled(id(cell))


# Player が移動する最短経路(通過する点の配列)を更新するメソッド
func update_path(start, end):
    # AStar で引数の出発地点から目的地点までの最短経路を探索する
	path = astar.get_point_path(id(start), id(end))


# グリッド座標からIDを生成するメソッド
func id(point):
	var a = point.x
	var b = point.y
	return (a + b) * (a + b + 1) / 2 + b

これで「Board (TileMap)」ノードでタイルが配置されているセルの座標に AStar の点が追加され、それぞれの点が線で繋がる。さらにその点のうち「Obstacle」インスタンスが配置されている座標と一致する点は無効化され、そこに繋がる線も無効化される。こうして最終的に形成された AStar のネットワークが経路として利用される。

update_path()メソッドは「Game」ノードのスクリプトの方で呼び出す予定だ。このメソッドを呼び出す際に、引数startendに「Player」ノードの現在の位置と移動先の位置をそれぞれ渡すと、形成された AStar のネットワーク上最も短い経路を割り出して、その上を Player ノードが移動する。

もちろん「Obstacle」インスタンスの位置は AStar の点が無効化されているので、線が繋がっておらず移動できないようになっている。

この AStar の点と線がイメージしにくいかもしれないので、「AStarVisualizer」にスクリプトをアタッチして可視化していく。

AStarVisualizer (Control) ノードにスクリプトをアタッチする

AStar の点と線を可視化するために、「AStarVisualizer」にスクリプトをアタッチし、コードを以下のように編集する。

extends Control


onready var board: TileMap = get_parent().get_node("Board")
onready var astar: AStar2D = board.astar
onready var offset: Vector2 = board.half_cell_size


# シーンツリーにノードが読み込まれたら _draw() 関数を呼び出す
func _ready():
	_draw()

# スクリーンにAStar の点と線を描画するよう組み込み関数 _draw()を上書き
func _draw():
    # AStar の全ての点(ID)でループ処理
	for point in astar.get_points():
        # 点が無効化されていたらこの後の処理をスキップ
		if astar.is_point_disabled(point):
			print("astar point is disabled")
			continue
		
        # AStar の点(ID)からグリッド座標に変換
		var cell = astar.get_point_position(point)
        # グリッド座標からワールド座標に変換
		var pos = board.map_to_world(cell)
        # AStar の点のワールド座標をセルの左上角から中央にズラして描画する
		draw_circle(pos + offset, 4, Color.white)
		
        # 取得した AStar の点につながっている上下左右の点(ID)を全て取得
		var point_connections = astar.get_point_connections(point)
        # つながっている全ての点をワールド座標として格納するための配列
		var connected_positions = []
		
        # つながっている点でループ処理
		for connected_point in point_connections:
            # もしつながっている点が無効化されている場合はこの後の処理をスキップ
			if astar.is_point_disabled(connected_point):
				print("connected point is disabled")
				continue
            # つながっている点のIDをグリッド座標に変換
			var connected_cell = astar.get_point_position(connected_point)
			# グリッド座標からワールド座標に変換
            var connected_pos = board.map_to_world(connected_cell)
            # ワールド座標を配列に追加
			connected_positions.append(connected_pos)
		
        # つながっている点のワールド座標の配列の要素でループ処理
		for connected_pos in connected_positions:
            # 元の点とそれに接続している点をつなぐ線を描画する
			draw_line(pos + offset, connected_pos + offset, Color.white, 2)

このスクリプトにより、AStar の点と線が画面に描画され、AStar のネットワークが可視化できるようになった。プロジェクトを実行すると以下のように表示されるはずだ。
run project


Game ノードにスクリプトをアタッチする

最後にマウスの入力操作で「Player」ノードが移動するようにコーディングしていく。

「Game」ルートノードにスクリプトをアタッチしたら、以下のようにコードを編集する。

extends Node2D

# Board ノードの参照
onready var board = $Board
# Line ノードの参照
onready var line = $Line
# Player ノードの参照
onready var player = $Player


func _input(event):
    # マウスの左ボタンがクリックされたら
	if event.is_action_pressed("move_to"):
        # マウスカーソルのグローバルな x, y 座標からタイルマップ上のセルの座標を目的地として取得
		var target_cell = board.world_to_map(get_global_mouse_position())
        # 目的地のセルの座標から AStar 用の ID を生成
		var target_cell_id = board.id(target_cell)
        # ID が AStar の有効な点に含まれている場合
		if board.astar.has_point(target_cell_id):
            # Player のゲーム画面上の x, y 座標からタイルマップ上のセルの座標を取得
			var player_cell = board.world_to_map(player.global_position)
            # Player のセルから目的地のセルの経路を更新する
			board.update_path(player_cell, target_cell)
            # Player ノードを移動させるメソッドを呼び出す
			move()


# プレイヤーの駒を移動させるメソッド
func move():
    # 移動中はクリック操作ができないようにインプットプロセスを無効にする
	set_process_input(false)
    # 経路を構成する点のセル座標でループ処理して Line ノードのパスを描画
	for point in board.path:
        # 点のゲーム画面上の x, y 座標を Line ノードのパスに追加
		line.add_point(board.map_to_world(point) + board.half_cell_size)
	# 経路を構成する点のセル座標でループ処理して Player ノードを移動
	for point in board.path:
        # 点のゲーム画面上の x, y 座標を Player ノードの位置として上書きする
		player.global_position = board.map_to_world(point)
        # 0.1秒待機
		yield(get_tree().create_timer(0.1), "timeout")
	# 移動が完了したら Line ノードのパスの点を消去
	line.clear_points()
    # インプットプロセスを有効にする
	set_process_input(true)

このスクリプトにより、マウスの左クリックで「Player」ノードの移動が可能になった。「Player」ノードがあるグリッド座標から左クリック時のマウスカーソルが重なっているグリッド座標までの最短経路を AStar アルゴリズムによって割り出し、その経路に沿って「Player」ノードを移動させている。

プロジェクトを実行すると以下のGIF画像のような動作を確認できるはずだ。
run project



おわりに

今回は AStar アルゴリズムを利用したグリッドベースの 2D 経路探索を紹介した。今回作成したプロジェクトは、タイルや障害物の配置を変えても同様に動作するはずだ。ポイントをまとめておこう。

  • Godot では AStar クラスが最初から用意されているのでそれを利用する。
  • ワールド座標 ⇄ グリッド座標 ⇄ ID の変換をそれぞれ適宜行う。
  • 経路探索の順序は次の通り。
    1. AStar の点を追加
    2. AStar の隣接する点と点を接続
    3. 障害物と重なっている AStar の点を無効化
      *もちろん手順 1 の時点で障害物を除いた点のみを追加する方法でも良い。
    4. 現在地と目的地の最短経路を AStar アルゴリズムで導く

また、以下のようなアレンジを加えると、よし面白いゲームができるかもしれない。

  • タイルや障害物をランダム生成させる。
  • 敵、味方それぞれ複数キャラクターを盤面上に配置し移動させる。
  • 上下左右の四方向に加えて、斜め方向への移動も可能にする。

参考

今回の記事を作成するにあたって、以下のリソースが非常に参考になったのでご紹介させていただく。