Godot 4 插件 - Utility AI 研究

今天看到一个视频教学

Godot4 | 实现简单AI | Utility AI 插件_哔哩哔哩_bilibili

就看了一下。吸引我的不是插件,是AI这两个字母。这AI与Godot怎么结合?感觉还是离线使用,值得一看。

视频时间不长,15分钟左右,看得我云山雾罩,不过演示项目能直接下载(AI Demo.zip官方版下载丨最新版下载丨绿色版下载丨APP下载-123云盘

下载下来,能运行,是个小游戏,不过逻辑没大看明白,可能以后看明白后会觉得很简单,但初接触,里面的弯弯绕那么多,一时不好理。

看介绍里还有一个插件自带Demo(godot-utility-ai-examples.zip官方版下载丨最新版下载丨绿色版下载丨APP下载-123云盘),感觉会简单一些。下载打开一看,果然简单很多。

插件自带Demo

因为Demo就一个场景AgentExample,且子节点就两个,这样就清爽了。

 不过运行一下,感觉没啥吸引力,就几个数字在那里变来变去。怎么能与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)

就是把几个进度条的显示与needs的相应信号绑定到一起了,每个显示的处理逻辑都是一样的

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

这好象没啥,数据正常显示。

哦,数据怎么来的?这个needs变量是AgentNeeds类型,从agent_needs.gd来看,这是一个Resource。

# 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有点意思,在资源里还带有逻辑。这不闹嘛,还是脚本。在理解的领域,把资源与脚本画一个约等于符号。

这个资源有三个属性,对应三个写方法,然后会触发三个相应的信号。仅此而已。这还是没有看到数据的起源。

再看一下脚本情况,还剩下一个agent.gd,是绑定到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()

在Timer的时钟事件中,根据当前的状态,修改相应属性值,这样界面上的数据就不断变化。

看代码时,发现还有个_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的情况下,会进行decision计算,否则不计算,保持现状。大概应该是这个意思。

但decision计算是要干啥?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三个逻辑,这些逻辑最终都会发出信号state_changed,该信号绑定到主场景脚本中的%StateLabel._on_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的定义,里面有几个UtilityAIOption,带有一个tres参数。跟进查看源码,UtilityAIOption一共有三个参数:behavior、context、action

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()计算出各选项的实时值。然后从低到高排序,如果有容许误差(tolerance),则过滤筛选,可能结果不止一个,则pick_random随机选一个。

所以,还得看各选项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)

各个behavior根据context进行计算,其各个考虑因子consideration(UtilityAIConsideration)分别计算得到结果,成为一个数列scores: Array[float],再根据aggregation类型确定最终结果的生成逻辑

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,了解了:

所以,最终的问题是:behavior中的各consideration是啥,怎么来的?

回到_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
	),
]

应该从这三个tres中找答案。比如eat.tres

这就对上了,原来在这里定义了各要素:Aggregation为Product,表示最终结果连乘。不过只有一个Consideration,所以连不连的也就一样了。

sleep.tres、watch_tv.tres也同样理解。

这里面还有一点,就是各Consideration的定义,它是用图表示出来的,看起来很直观,其实不太好定量理解,这个既然是算法逻辑,那还是精确一些好理解,但画成图形,尤其是还有一大堆参数可调,就感觉不好控制了。不过目前暂看图形曲线,能看到IO大概关系,参数什么的暂不关心。

到此,整个流程清晰了:

1. Agent的Timer周期性(1s)处理:

1.1 每秒根据状态,调整needs的food、energy、fun三个属性,从而触发needs的三个信号。这三个信号绑定到界面的三个进度条,从而三个进度条显示相应属性值大小

1.2 决策时刻(秒)减1。如果<=0,则进行决策,决策结果会影响状态。而决策过程就是UtilityAI.choose_highest(_options),即各选项自行根据输入计算得到自己的输出,然后由UtilityAI筛选出目标选项。确定后,触发目标选项的action(分别动态赋值为agent.gd中的eat、sleep、watch_tv函数),更新相应状态并触发信号,由主场景的_on_state_changed函数显示相应的状态信息。

B站AI Demo

现在回来看B站的Demo项目。现在回来,直接看重点:agent的tres

一共有三个tres:attack、chase、run_away,那应该会有三个状态,结果是4个

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

这也不能说是理解错误,反而是十分正确与准确。

attack.tres是Product模式,一个Consideration,嗯,很好理解

chase.tres是Product模式,三个Considerations,run_away.tres是Product模式,四个Considerations,同样好理解。这些就是在各选项的实时计算时的依据。

下来就是看各选项的定义,肯定会与这三个tres有关

@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
	)
]

 果真如此。这里的needs为输入,第三个参数将在相应的选项被选中后调用。

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的绑定处理函数。难道是没有用这个信号?原来视频里提醒过了:信号没有使用。那好吧,这就是只改变内部的状态,外部不需要显示或处理这个信号。

同样,不用猜,还会有一个Timer来处理。该Timer的时钟周期为0.4s:

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()

与自带Demo的区别在于,这里的_options中的needs输入,是从父场景中取得的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的任务好象就完成了:时钟中获取实时数据,判断目标选项,调用目标选项的action,其中完成内部的状态改变。

这是什么AI?感觉就是一个简单的逻辑

再看了一下Demo项目,感觉内容比较多,主要是碰撞相关内容处理、动画效果展示,还有就是路径规划。呃,路径规划_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
今日推荐