Godot 4 プラグイン - ユーティリティ AI 研究

今日ビデオチュートリアルを見ました

Godot4 | シンプルAIの実現 | ユーティリティAIプラグイン_哔哩哔哩_bilibili

ちょっと見てみましょう。私が惹かれるのはプラグインではなく、AIの二文字です。この AI はどのように Godot と結合するのでしょうか? オフラインでもまだ配信されているようなので、一見の価値はあります。

動画時間は約 15 分と長くはありませんが、雲と山が見えますが、デモ プロジェクトは直接ダウンロードできます ( AI Demo.zip 正式版ダウンロード丨最新バージョン ダウンロード丨グリーン バージョン ダウンロード丨APP ダウンロード-123 クラウド ディスク

ダウンロード後、実行できます。小さなゲームですが、ロジックはあまり明確ではありません。後で理解すると、非常に単純であることがわかるかもしれません。しかし、最初に理解するときは、非常に多くの紆余曲折があります。しばらくの間、理解するのが難しいです。

導入部にはプラグインのデモ(godot-utility-ai-examples.zip 正式版ダウンロード丨最新版ダウンロード丨グリーン版ダウンロード丨APP ダウンロード-123 クラウドディスク)もありますので、より簡単に感じます。ダウンロードして開いてみると、とても簡単です。

プラグインにはデモが付属しています

デモには、AgentExample という 1 つのシーンと 2 つの子ノードしかないため、すっきりしています。

 しかし、実行してみると、数個の数字が変わるだけで、あまり魅力的ではないと感じます。どうすればAIとつながることができるのでしょうか?

それは私の問題理解に違いありません、もう一度見てください

メインシーンの脚本はシンプル

func _ready():
	var needs: AgentNeeds = $Agent.needs
	needs.food_changed.connect(%FoodBar._on_needs_changed)
	needs.fun_changed.connect(%FunBar._on_needs_changed)
	needs.energy_changed.connect(%EnergyBar._on_needs_changed)

	$Agent.state_changed.connect(%StateLabel._on_state_changed)

これは、複数のプログレスバーの表示を対応するニーズの信号とバインドするものであり、各表示の処理ロジックは同じです

func _on_needs_changed(p_value: float) -> void:
	value = p_value

これは何もないようで、データは正常に表示されます。

ああ、データはどこから来たのですか? ニーズ変数は AgentNeeds タイプであり、agent_needs.gd のリソースです。

# Copyright (c) 2023 John Pennycook
# SPDX-License-Identifier: 0BSD
class_name AgentNeeds
extends Resource


signal food_changed(value)
signal fun_changed(value)
signal energy_changed(value)


@export var food := 0.5 : set = _set_food
@export var fun := 0.5 : set = _set_fun
@export var energy := 0.5 : set = _set_energy


func _set_food(p_food: float) -> void:
	food = clamp(p_food, 0.0, 1.0)
	food_changed.emit(food)


func _set_fun(p_fun: float) -> void:
	fun = clamp(p_fun, 0.0, 1.0)
	fun_changed.emit(fun)


func _set_energy(p_energy: float) -> void:
	energy = clamp(p_energy, 0.0, 1.0)
	energy_changed.emit(energy)

Godot は少し面白く、リソースにはロジックがあります。これは冗談ではなく、まだ脚本です。理解の分野では、リソースとスクリプトの間に近似記号を描きます。

このリソースには 3 つの書き込みメソッドに対応する 3 つの属性があり、対応する 3 つの信号をトリガーします。それで全部です。これではまだデータの出所がわかりません。

もう一度スクリプトを確認すると、agent.gd が 1 つ残っており、これは Agent ノードにバインドされたスクリプトです。ここに入り口はありますか?

ああ、Agent ノードの下に Timer ノードがあるのを見ると、常に何かを行っているのは Timer ノードに違いありません。スクリプトを開いて確認してください。

# Copyright (c) 2023 John Pennycook
# SPDX-License-Identifier: 0BSD
class_name Agent
extends Node2D


signal state_changed(state)


enum State {
	NONE,
	EATING,
	SLEEPING,
	WATCHING_TV,
}


@export var needs: AgentNeeds
var state: State = State.EATING


var _time_until_next_decision: int = 1


@onready var _options: Array[UtilityAIOption] = [
	UtilityAIOption.new(
		preload("res://examples/agents/eat.tres"), needs, eat
	),
	UtilityAIOption.new(
		preload("res://examples/agents/sleep.tres"), needs, sleep
	),
	UtilityAIOption.new(
		preload("res://examples/agents/watch_tv.tres"), needs, watch_tv
	),
]


func eat():
	state = State.EATING
	_time_until_next_decision = 5
	state_changed.emit(state)


func sleep():
	state = State.SLEEPING
	_time_until_next_decision = 10
	state_changed.emit(state)


func watch_tv():
	state = State.WATCHING_TV
	_time_until_next_decision = 1
	state_changed.emit(state)


func _on_timer_timeout():
	# Adjust the agent's needs based on their state.
	# In a real project, this would be managed by something more sophisticated!
	if state == State.EATING:
		needs.food += 0.05
	else:
		needs.food -= 0.025

	if state == State.SLEEPING:
		needs.energy += 0.05
	else:
		needs.energy -= 0.025

	if state == State.WATCHING_TV:
		needs.fun += 0.05
	else:
		needs.fun -= 0.025

	# Check if the agent should change state.
	# Utility helps the agent decide what to do next, but the rules of the game
	# govern when those decisions should happen. In this example, each action
	# takes a certain amount of time to complete, but the agent will abandon
	# eating or sleeping when the associated needs bar is full.
	if (
		(state == State.SLEEPING and needs.energy == 1)
		or (state == State.EATING and needs.food == 1)
	):
		_time_until_next_decision = 0

	if _time_until_next_decision > 0:
		_time_until_next_decision -= 1
		return

	# Choose the action with the highest utility, and change state.
	var decision := UtilityAI.choose_highest(_options)
	decision.action.call()

タイマーのクロック イベントで、インターフェイス上のデータが常に変化するように、現在の状態に応じて対応するプロパティ値を変更します。

コードを見てみると、_time_until_next_decision という変数もあり、名前を見ると意思決定の時間を表す機能になっています。本当の論理は

	if _time_until_next_decision > 0:
		_time_until_next_decision -= 1
		return

	# Choose the action with the highest utility, and change state.
	var decision := UtilityAI.choose_highest(_options)
	decision.action.call()

つまり、_time_until_next_decision <= 0 の場合は判定計算が行われ、それ以外の場合は判定計算は行われず現状維持となります。おそらくこれを意味するはずです。

しかし、決定計算は何のために行われるのでしょうか? UtilityAI.choose_highest(_options) は、いくつかのオプションの中で最も優先度の高い項目、または最も重要な項目、つまり最も重要な項目を選択する必要があります。_options の定義を確認できます。

@onready var _options: Array[UtilityAIOption] = [
	UtilityAIOption.new(
		preload("res://examples/agents/eat.tres"), needs, eat
	),
	UtilityAIOption.new(
		preload("res://examples/agents/sleep.tres"), needs, sleep
	),
	UtilityAIOption.new(
		preload("res://examples/agents/watch_tv.tres"), needs, watch_tv
	),
]

Eat、Sleep、watch_tv の 3 つのロジックの 3 つの項目だけで、これらのロジックは最終的に、メイン シーン スクリプトの %StateLabel._on_state_changed にバインドされている信号 state_changed を送信し、単にコンテンツを表示します。

func _on_state_changed(state: Agent.State) -> void:
	match state:
		Agent.State.EATING:
			text = "Eat"
		Agent.State.SLEEPING:
			text = "Sleep"
		Agent.State.WATCHING_TV:
			text = "Watch TV"

ここで、基本的には _options オプションを定義し、UtilityAI.choose_highest(_options) を使用してターゲット オプションを取得し、対応するロジックをトリガーすることが中心であると理解しました。

わかっているようでわかっていないよく考えてみると、一番重要な関数、UtilityAI.choose_highest(_options) はどのように動作するのでしょうか?最も重要で重要なオプションをどのように選択できるのでしょうか?プログラマーはこのプロセスで何を設計できるでしょうか?

UtilityAI は一般的な処理メソッドである必要があるため、この答えは UtilityAI のコード内に存在しないはずです。ここで説明したオプションはビジネスに関連するものであり、プログラマが処理する必要があります。

_options の定義を振り返ると、tres パラメーターを持つ UtilityAIOptions がいくつかあります。フォローアップしてソース コードを表示します。UtilityAIOption には、動作、コンテキスト、アクションの 3 つのパラメータがあります。

func _init(
	p_behavior: UtilityAIBehavior = null,
	p_context: Variant = null,
	p_action: Variant = null
):
	behavior = p_behavior
	context = p_context
	action = p_action

UtilityAI.choose_highest(_options) はクラス関数です

static func choose_highest(
	options: Array[UtilityAIOption], tolerance: float = 0.0
) -> UtilityAIOption:
	# Calculate the scores for every option.
	var scores := {}
	for option in options:
		scores[option] = option.evaluate()

	# Identify the highest-scoring options by sorting them.
	options.sort_custom(func(a, b): return scores[a] < scores[b])

	# Choose randomly between all options within the specified tolerance.
	var high_score: float = scores[options[len(options) - 1]]
	var within_tolerance := func(o): return (
		absf(high_score - scores[o]) <= tolerance
	)
	return options.filter(within_tolerance).pick_random()

各オプションの option.evaluate() を通じて、各オプションのリアルタイム値を計算します。次に、低いものから高いものへと並べ替えます。許容範囲 (許容値) がある場合は、フィルターとスクリーニングを行い、複数の結果が存在する可能性があります。pick_random はランダムに 1 つを選択します。

したがって、各オプション option.evaluate() がどのように機能するかによって異なります。

func evaluate() -> float:
	return behavior.evaluate(context)
func evaluate(context: Variant) -> float:
	var scores: Array[float] = []
	for consideration in considerations:
		var score := consideration.evaluate(context)
		scores.append(score)
	return _aggregate(scores)

各行動はコンテキストに従って計算され、各考慮要素 (UtilityAIDiscussion) が個別に計算されて結果が得られ、スコアの配列: Array[float] となり、集計に従って最終結果の生成ロジックを決定しますタイプ

func _aggregate(scores: Array[float]) -> float:
	match aggregation:
		AggregationType.PRODUCT:
			return scores.reduce(func(accum, x): return accum * x)

		AggregationType.AVERAGE:
			return scores.reduce(func(accum, x): return accum + x) / len(scores)

		AggregationType.MAXIMUM:
			return scores.max()

		AggregationType.MINIMUM:
			return scores.min()

	push_error("Unrecognized AggregationType: %d" % [aggregation])
	return 0

ここでは Array.reduce 関数を使用していますが、これまでこの関数を使用したことがないため、これらのコードの結果はわかりません。しかし、ChatGPT に尋ねて、次のことを理解してください。

最後の質問は、その行動にはどのような考慮事項があり、それらはどのようにして生まれたのかということです。

_options の定義に戻る

@onready var _options: Array[UtilityAIOption] = [
	UtilityAIOption.new(
		preload("res://examples/agents/eat.tres"), needs, eat
	),
	UtilityAIOption.new(
		preload("res://examples/agents/sleep.tres"), needs, sleep
	),
	UtilityAIOption.new(
		preload("res://examples/agents/watch_tv.tres"), needs, watch_tv
	),
]

答えはこれら 3 つのトレスにあるはずです。たとえば、eat.tres

そうです。元の要素はここで定義されています。集計は積です。これは、最終結果が乗算されることを意味します。しかし、考察は一つしかないので、繋がっていても繋がっていなくても同じです。

sleep.tres、watch_tv.tres も理解できます。

ここでもう 1 つのポイントは、各考慮事項の定義です。これはグラフで表され、非常に直感的に見えますが、定量的に理解するのは簡単ではありません。これはアルゴリズムロジックであるため、より正確で理解しやすいです。 , グラフとして描くこともできますが、特に調整できるパラメータが多いので制御が難しく感じます。ただし、とりあえずグラフの曲線を見ればIOの大まかな関係は分かるので、パラメータは当面は気にしないでください。

この時点で、プロセス全体は明らかです。

1. エージェントのタイマーの定期的 (1 秒) の処理:

1.1 ニーズの食べ物、エネルギー、楽しみの 3 つの属性を秒ごとの状態に応じて調整し、3 つのニーズ信号をトリガーします。これら 3 つの信号はインターフェイスの 3 つのプログレス バーにバインドされているため、3 つのプログレス バーには対応する属性値のサイズが表示されます。

1.2 決定時間 (秒) から 1 を引いた値。<=0 の場合、決定が行われ、決定の結果が状態に影響を与えます。意思決定プロセスは UtilityAI.choose_highest(_options) です。つまり、各オプションは入力に基づいて独自の出力を計算し、その後、ターゲット オプションが UtilityAI によって選択されます。確認後、ターゲット オプション (agent.gd の Eat、sleep、watch_tv 関数にそれぞれ動的に割り当てられた) のアクションをトリガーし、対応する状態を更新してシグナルをトリガーすると、メイン シーンの _on_state_changed 関数によって対応するものが表示されます。状態情報。

B站AI Demo

ここで、ステーション B のデモ プロジェクトを見てください。今戻って、重要なポイントであるエージェントのトレスを直接見てください。

合計 3 つのトレスがあります: 攻撃、追跡、逃走、3 つの状態があるはずで、結果は 4 です。

enum State {
	IDLE,
	CHASE,
	RUN_AWAY,
	ATTACK,
}

これは誤解とは言えませんが、非常に正しく正確です。

Attack.tres は製品モードであり、考慮事項です。うーん、よくわかりました。

Chase.tres は Product モード、3 つの考慮事項、run_away.tres は Product モード、4 つの考慮事項、これも理解しやすいです。これらは、オプションのリアルタイム計算の基礎となります。

次のステップは、各オプションの定義を確認することです。これらの 3 つのトレースに間違いなく関連しています。

@onready var _options: Array[UtilityAIOption] = [
	UtilityAIOption.new(
		preload("res://Enemy/agent/attack.tres"), needs, attack
	),
	UtilityAIOption.new(
		preload("res://Enemy/agent/chase.tres"), needs, chase
	),
	UtilityAIOption.new(
		preload("res://Enemy/agent/run_away.tres"), needs, run_away
	)
]

 それは正しい。ここで必要なのは入力であり、対応するオプションが選択された後に 3 番目のパラメーターが呼び出されます。

func idle():
	state = State.IDLE
	state_changed.emit(state)


func chase():
	state = State.CHASE
	state_changed.emit(state)


func run_away():
	state = State.RUN_AWAY
	state_changed.emit(state)


func attack():
	state = State.ATTACK
	state_changed.emit(state)

一目見ただけで懐かしい香りがします。しかし、コードを検索したところ、state_changed バインディング ハンドラーが見つかりませんでした。この信号は使われていないのでしょうか?信号は使用されていないことがビデオで思い出されたことが判明しました。まあ、これは内部状態を変更するだけであり、外部はこの信号を表示したり処理したりする必要はありません。

繰り返しますが、推測する必要はありません。それを処理するタイマーがあります。タイマーのクロック サイクルは 0.4 秒です。

func _on_timer_timeout() -> void:
	var needs_info = get_parent().get_ai_needs()
	
	for key in needs_info.keys():
		needs.set(key, needs_info[key])
	
	var decision := UtilityAI.choose_highest(_options)
	decision.action.call()

組み込みのデモとの違いは、ここでの _options のニーズ入力が親シーンから取得されたget_parent().get_ai_needs()であり、親シーンによって提供されるリアルタイム入力データと同等であることです。

func get_ai_needs() -> Dictionary:
	return {
		"my_hp": hp / enemy_hp,
		"player_hp": _player_node.hp / _player_node.max_hp,
		"partners": 1.0 if _partners > 3 else _partners / 3,
		"could_hit_player": _could_hit_player,
		"could_run_away": _could_run_away,
	}

この UtilityAI のタスクは完了しているようです。時計からリアルタイム データを取得し、ターゲット オプションを判断し、ターゲット オプションのアクションを呼び出し、内部状態の変更を完了します。

これはどのようなAIですか? 単純なロジックのような気がする

デモプロジェクトを改めて見てみると、主に衝突関連のコンテンツ処理やアニメーション効果の表示、パスプランニングなどのコンテンツが増えているように感じます。え、経路計画_make_path、AIの仕業ですか? ソースコードを見ると、NavigationAgent2Dの仕業でAIとは関係ありません。

@onready var nav_agent: NavigationAgent2D = $NavigationAgent2D

func _make_path() -> void:
	match $Agent.state:
		1:
			nav_agent.target_position = _player_node.global_position
		2:
			var _partner_nodes = get_tree().get_nodes_in_group("enemy")
			if len(_partner_nodes) == 1:
				_could_run_away = 0.0
			else:
				var _partner = [null, INF]
				for _pt in _partner_nodes:
					if _pt == self:
						continue
					
					var _partner_distance = global_position.distance_to(_pt.global_position)
					if _partner_distance < _partner[1]:
						_partner[0] = _pt
						_partner[1] = _partner_distance
					
					nav_agent.target_position = _partner[0].global_position
					_could_run_away = 1.0

でも、まあ、AI は AI です、結局のところ、それらの出力はコンピューターによって計算されます。

おすすめ

転載: blog.csdn.net/drgraph/article/details/131991269
おすすめ