ぬーぶのメモ帳

フリーゲームエンジン「Godot」でプログラム経験のない素人の備忘録 最近は色々な動作を考えて試すのが好き。

【ゲームAI】有限状態機械の練習

コンピューターの知性
まずこちらが完成品となります、
食料と水を求め、毒マスで死亡、条件を満たすと増殖する生き物です。

これらの限定的な行動を「有限状態機械」 「有限オートマトン」と呼び、いくつかの条件分岐から、それらに沿った行動を止めるまで動き続ける物のようです。
実を言えば ■以前の記事 でもやっているのですが当時は名前も知らずに使っていて書籍を読んで初めて名前を知りましたw

今回解説書のC言語で書かれたものを参考にGDscriptへ 自分なりに 落とし込んでみました、よろしければお付き合いください。


 

有限状態機械って?

有限状態機械、書籍によると
「何種類かの状態をいずれか1つの状態で存在することができる抽象機械」との事、
わかりやすい例だと「パックマンのおばけ」がこれにあたります。あれです。

パックマンを探し、見つけたら追いかけ、無敵になられたら逃げ、パックマンに食べられたら巣に戻る。

ゲーム中に予想される行動へ分岐させながらゲームが終わるまで動き続ける…昔のゲームでも立派に役割を果たしていたようです。今でも有効なら使わない手はないですね。

下の図を見てください

この図が今回使用するAIの行動になります。

1. 食料を探しランダムに移動
2. 食料を見つけたら家に持ち帰る
3. 家に戻ると増殖、戻ったキャラは水場を探す
4. 水場に到着したらまた食料を探す
基本は1~4を繰り返します

5. 毒を拾った場合死亡する
そして毒を拾うことで初めてループが止まります


素材と使用タイルの設定

使用する素材はこちら
 
16x16のミニサイズで、左が生き物 右がマップタイル一覧になります。
なお書籍ではアリがモデルです。

使用ノードは
・TileMap
 ・Node2D(キャラクター格納用コンテナ)
  ・Sprite2D(インスタンスで生成)

今回はタイルマップのカスタムデータを使用してタイルに数字、属性をつけていきます。

エディタの下部、TileSet から先ほど追加したカスタムデータを設定、数字による属性をつけていく。

ここで設定した数値をスクリプトで入手して、それを基にキャラクターの行動が変化させます。


タイルマップのコード

タイルに番号も付けたのでスクリプトに移ります。
■ タイルマップ

extends TileMap
const ARI = preload("res://ari.tscn")
@onready var container: Node2D = $c_container

# フィールドのXY最大幅
@export var max_x :int = 45
@export var max_y :int = 35
# 食料、水、独の最大数
@export var max_food   :int = 15
@export var max_drink  :int = 10
@export var max_poison :int = 8


func  _ready() -> void:
	## ゲーム初期化はじめ
	for x in range(max_x): # 地面を作成
		for y in range(max_y):
			set_cell(0, Vector2i(x, y), 0, Vector2i(0, 0))
			
	for x in range(max_x): # 99ブロックで囲いを作成
		set_cell(0, Vector2i(x, 0), 0, Vector2i(6, 0))
		set_cell(0, Vector2i(x, max_y), 0, Vector2i(6, 0))
	for y in range(1, max_y):
		set_cell(0, Vector2i(0, y), 0, Vector2i(6, 0))
		set_cell(0, Vector2i(max_x-1,y), 0, Vector2i(6, 0))

	# ホームを配置
	set_cell(0, find_random_cell(), 0, Vector2i(2, 0))
	for i in max_food:# 食料を配置
		set_cell(0, find_random_cell(), 0, Vector2i(3, 0))
	for i in max_drink:# 水を配置
		set_cell(0, find_random_cell(), 0, Vector2i(4, 0))
	for i in max_poison:# 毒を配置
		set_cell(0, find_random_cell(), 0, Vector2i(5, 0))

	for i in range(4): # キャラクターを作る
		ari_spawn()
	## ゲーム初期化終わり
	

# 0タイル、草原の位置を見つける関数
# 見つけた場合その座標を返す
func find_random_cell()-> Vector2i:
	var dx := 0
	var dy := 0
	while true:
		randomize()
		dx = randi() % max_x
		dy = randi() % max_y
		var cell = get_cell_tile_data(0, Vector2i(dx, dy))
		if cell.get_custom_data("tile_attribute") == 0:
			break
	return Vector2i(dx, dy)
	
# アリをポップさせる、初期位置はアリの方で設定している
func  ari_spawn()-> void:
	var ari_co = container.get_child_count()
	if ari_co < 30:
		var ari = ARI.instantiate()
		container.add_child(ari)
		print("システム : ", ari_co)
	else :
		print("システム : おおすぎ")

 

タイルマップは主にフィールドの初期化、キャラクターとアイテムの補充を行うシステム面を担います。
まず _ready() 内でフィールドを初期化、最初は for文で土台を作成しただけです。

次のホーム、食料、水、毒の追加では関数を作成して確実に何もない地面へアイテムを設置できるようにしました。

# TileMap
# 指定位置に指定したタイルを貼る
set_cell(0, find_random_cell(), 0, Vector2i(2, 0))

 set_cell(tilesetのレイヤー, タイルの設置座標, tilesetのID, 指定タイルの座標)

慣れないうちは Vector2i が2つあり戸惑いますね。
先がフィールドに設置する座標後がTileSet内のタイルの種類 を表しています。
タイルが座標で定義されているイメージついては下になります。

godot4 になってからタイルマップの仕様が大きく変わりました、詳しくは解説している動画なども沢山あるのでそちらを参照にしてください。

■ 地面を指定する関数

func find_random_cell()-> Vector2i:
	var dx := 0
	var dy := 0
	while true:
		randomize()
		dx = randi() % max_x
		dy = randi() % max_y
		var cell = get_cell_tile_data(0, Vector2i(dx, dy))
		if cell.get_custom_data("tile_attribute") == 0:
			break
	return Vector2i(dx, dy)

やっている事は単純でフィールドをランダム指定し、何もない地面であればその座標を返します。
ただここでポイントとなる命令が2つあります。

 get_cell_tile_data(タイルレイヤー, Vector2i(x, y))
 get_custom_data("カスタムデータの名前")

get_cell_tile_data() は指定したフィールド箇所の TileData という型を返します、この中にはもちろん最初に設定したカスタムレイヤーのデータも含まれます

そしてget_custom_data()カスタムデータの名前を指定することで内包されたデータを取得できるようになります、今回で言うと int型 ですね。

今回は get_cell_tile_data()get_custom_data() のコンビを多用して、指定マスのデータ取得、タイル属性を引き出しゲーム内判定に使用しています。


ari_spawn() に関してもコンテナ内にいるキャラの数を監視し指定した数より増えないようにしているだけで、インスタンスで増やすだけです。


キャラクターのコード

動的な動きは全てキャラクター側で判断します
食料や水、毒をとった際もキャラクター側がタイルマップにアクセスし補充されます。

利点はそれぞれのキャラクターがアイテムを取るたびに追加の命令を出すので、今の仕様では配列による管理をしなくていいのが楽だと思います。
(一括で管理したくなったら結局必要そうですが)

以下がキャラクターのコードです。

■ キャラクター

extends Sprite2D

# タイルマップの命令にアクセスしやすいように定義
@onready var map :TileMap = get_parent().get_parent()
# キャラクターの出現位置と帰宅用の座標変数
var my_home := Vector2i.ZERO
# キャラクターの現在位置
var px :int
var py :int
# 移動量を格納する変数
var move_x :int
var move_y :int

# 有限状態機械で使うステータス状態
enum state {FORAGE = 1, GOHOME, THIRSTY, DEAD}
var current_state := 0

# 初期ステータスを定義 食べ物を探す
func _init() -> void:
	current_state = state.FORAGE

func  _ready() -> void:
	var home_pos = map.get_used_cells_by_id(0, 0, Vector2i(2, 0))
	my_home = home_pos[0]
	px = my_home.x
	py = my_home.y
	position = Vector2(px*16, py*16)


func _on_timer_timeout() -> void:
	# ここで条件を判定分岐させて行動させる
	match current_state:
		state.FORAGE:
			forage()
			return
		
		state.GOHOME:
			gohome()
			return

		state.THIRSTY:
			thirsty()
			return
		
		state.DEAD:
			chara_dead()
			queue_free()


## 各ステータスの行動を書き込んでいる
func forage()-> void:
	randomize() # ランダムで移動先を決定
	move_x = randi_range(-1, 1)
	move_y = randi_range(-1, 1)
	
	# 移動先のタイルマップデータを取得
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	# 取得したデータから設定したcellの属性数値を取得
	if get_cell.get_custom_data("tile_attribute") == 99: # 99. 黒い壁
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else :
		px += move_x
		py += move_y

	# 0、草原 1、家 3、水場
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 1\
	or get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 2:
		position = Vector2(px*16, py*16)		
		# 食料を食べると新しい食料をポップさせる
		map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0)) # 自分の場所を平原に
		map.set_cell(0, map.find_random_cell(), 0, Vector2i(3, 0)) # タイルマップのほうで食料を出現させる
		current_state = state.GOHOME
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return


func  gohome()-> void:
	# 自身の位置とホームを比較して真っすぐ帰る
	if px < my_home.x:
		move_x = +1
	elif px > my_home.x:
		move_x = -1
	else :
		move_x = 0
	
	if py < my_home.y:
		move_y = +1
	elif py > my_home.y:
		move_y = -1
	else :
		move_y = 0
	
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	if get_cell.get_custom_data("tile_attribute") == 99:
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else:
		px += move_x
		py += move_y
	
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 2\
	or get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 1:
		position = Vector2(px*16, py*16)		
		get_parent().get_parent().ari_spawn()
		current_state = state.THIRSTY
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return

func thirsty()-> void:
	randomize()
	move_x = randi_range(-1, 1)
	move_y = randi_range(-1, 1)
	
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	if get_cell.get_custom_data("tile_attribute") == 99:
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else :
		px += move_x
		py += move_y

	# 0、草原 1、家 2、食料
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 1\
	or get_cell.get_custom_data("tile_attribute") == 2:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)		
		map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0))
		map.set_cell(0, map.find_random_cell(), 0, Vector2i(4, 0))
		current_state = state.FORAGE
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return

func chara_dead()-> void:
	map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0))
	map.set_cell(0, map.find_random_cell(), 0, Vector2i(5, 0))



今回一定間隔でステップのように動かしたかったのでキャラクターにタイマーノードを埋め込み _on_timer_timeout()でループ処理を行っています。

最初に見える match は他の言語では switch文 と呼ばれているようです。
一致した時の命令は関数にしているので match の中はシンプルな状態です

	# ここで条件を判定分岐させて行動させる
	# current_stateが state と一致する時書かれている名前の中が処理される
	match current_state:
		state.FORAGE:
			forage()
			return
		
		state.GOHOME:
			gohome()
			return

		state.THIRSTY:
			thirsty()
			return
		
		state.DEAD:
			chara_dead()
			queue_free()
			return

 
まずは FORAGE から見ます。
■ FORAGE

func forage()-> void:
	randomize() # ランダムで移動先を決定
	move_x = randi_range(-1, 1)
	move_y = randi_range(-1, 1)
	
	# 移動先のタイルマップデータを取得
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	# 取得したデータから設定したcellの属性数値を取得
	if get_cell.get_custom_data("tile_attribute") == 99: # 99. 黒い壁
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else :
		px += move_x
		py += move_y

	# 0、草原 1、家 3、水場
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 1\
	or get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 2:
		position = Vector2(px*16, py*16)		
		# 食料を食べると新しい食料をポップさせる
		map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0)) # 自分の場所を平原に
		map.set_cell(0, map.find_random_cell(), 0, Vector2i(3, 0)) # タイルマップのほうで食料を出現させる
		current_state = state.GOHOME
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return

移動はランダム、0も含みその場で待機することもあります。
上で述べた通り get_cell_tile_data() と get_custom_data() のコンビを使い倒しています。

移動先のデータを取得、そこからカスタムデータの属性を抜き出して判定を繰り返しています。
99の×ブロックの場合移動はできないと警告が出力されます、そうでない場合は 変数move_x らが足され移動先が更新されます。

・0、1、3のマスであればそのまま移動、次の移動に備えます。
・2であれば食料を発見、 FORAGEの目的 は達成したので current_state = state.GOHOME とステータスが切り替わります。

・4だった場合、current_state = state.DEADに移りゲーム内から消去されます。



複雑な処理を行わない場合1つのステータスが完成すれば後は流用がきくのでサクサク作れました。
■ GOHOME

func  gohome()-> void:
	# 自身の位置とホームを比較して真っすぐ帰る
	if px < my_home.x:
		move_x = +1
	elif px > my_home.x:
		move_x = -1
	else :
		move_x = 0
	
	if py < my_home.y:
		move_y = +1
	elif py > my_home.y:
		move_y = -1
	else :
		move_y = 0
	
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	if get_cell.get_custom_data("tile_attribute") == 99:
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else:
		px += move_x
		py += move_y
	
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 2\
	or get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 1:
		position = Vector2(px*16, py*16)		
		get_parent().get_parent().ari_spawn()
		current_state = state.THIRSTY
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return

食料を入手した後、まっすぐ家に戻る命令を繰り返します。
無事に家まで到着して1,家のマスと重なると新しい個体を生成します。

今回は障害物のない平原なのでこれで問題ありません、もし入り組んだ地形を作る場合は経路探索アルゴリズムが必要になります。


■ THIRSTY

func thirsty()-> void:
	randomize()
	move_x = randi_range(-1, 1)
	move_y = randi_range(-1, 1)
	
	var get_cell = map.get_cell_tile_data(0, Vector2i(px + move_x, py + move_y))
	if get_cell.get_custom_data("tile_attribute") == 99:
		print("いどうできない ", get_cell.get_custom_data("tile_attribute"))
		return
	else :
		px += move_x
		py += move_y

	# 0、草原 1、家 2、食料
	if get_cell.get_custom_data("tile_attribute") == 0\
	or get_cell.get_custom_data("tile_attribute") == 1\
	or get_cell.get_custom_data("tile_attribute") == 2:
		position = Vector2(px*16, py*16)
		return
		
	elif get_cell.get_custom_data("tile_attribute") == 3:
		position = Vector2(px*16, py*16)		
		map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0))
		map.set_cell(0, map.find_random_cell(), 0, Vector2i(4, 0))
		current_state = state.FORAGE
		return
	
	elif get_cell.get_custom_data("tile_attribute") == 4:
		position = Vector2(px*16, py*16)
		current_state = state.DEAD
		return

キャラクターが再度ランダムで動き出します、今回は 2.肉のマス に止まっても見向きもしません。
3. 水のマス まで移動したら再度 current_state = state.FORAGE に戻り食料を探します。


■ DEAD

func chara_dead()-> void:
	map.set_cell(0, Vector2i(px, py), 0, Vector2i(0, 0))
	map.set_cell(0, map.find_random_cell(), 0, Vector2i(5, 0))

唯一ループから解放されるコマンドになります。
マップアイテムの再生成の場所指定に作った find_random_cell() が思った以上に便利でした。


まとめ

改めて学んでみると簡単に実装できて、それでいてキャラクターが生きているようにふるまうのは見ていて楽しいものでした。

ノードそれ自体が単体で動いてくれて、手軽にポコポコ増やすことができるのはやはりGodotの良いところだなあと再認識しました。
もっと使いこなせるようになれば、RPGRTS等のパターンを組んだりも可能になるのでしょうか、妄想が捗ります。

今回はここまでになります、最後までおつきあいくださりありがとうございました。



■ 参考書籍
www.oreilly.co.jp
かなり古い書籍になりますが、初学者の自分でも理解できるレベルでした。
しかしサンプルコードが書籍に記載されている場所にないのが痛いです。
(一応これがサンプルっぽいですが利用は自己責任でお願いします)
examples / AI for Game Developers · GitLab