草庐IT

AI行为树的基础运作原理

书封影 2024-01-21 原文

欢迎捉虫!

之前我研究了一下基于switch case语句的FSM状态机的使用,后来遇到了很多问题。

比如当角色的行为很多时,代码结构相当混乱(你需要考虑每一种状态之间的联系)。

所以,当角色的行为愈发的复杂,状态机的设计图就越像一坨蜘蛛网,维护是状态机所需的成本也就越高,这对于开发者来说显然很麻烦。

所以,在查找了许多资料后,我发现了行为树这一利器,于是好好学习了一番。然后发现,这玩意不仅是游戏开发的利器,对于游戏策划而言也是必不可少。

行为树到底是个啥?他的运作机制是什么?我该如何利用行为树来设计AI和人物运动脚本?

0 前言

更准确的说,行为树其实是一种反应型AI,这种AI人为控制性非常高,也意味着开发者要将AI的行为规划好,而这种规划方式之一便是行为树。(还有一种就是我们之前讲的FSM有限状态机)

比如这个就是一种行为树,显而易见的是,行为树的结构相当清晰,比一坨状态机好维护多了。

上图是一个有向无环图,也就是他会顺着箭头的指向走,而不会循环。箭头指向的各个动作会自动判定执行。 

当然,实际上行为树的实现逻辑可没有这么简单

准备好,我们开始。

1.0 行为树的组成

1.1最简单的行为树

我们先来画一个只有两个元素组成的行为树

在上图中,红色的点叫做父节点(Parent),蓝色的点叫做子节点(Children)。

对于任意一个节点,指向它的节点就是他的父节点,它指向的节点就是他的子节点

明白父子节点后,我们要思考,父子节点之间是通过何种关系来协作运作的。于是,我们就要介绍一下——行为树的工作流。

1.2行为树的工作流

为什么要引入工作流呢?这与行为树之间的关联有关。只有通过工作流,我们才能把行为树中的各个部分给联系起来。

工作流这个名词非常高级,但是在行为树里面就比较通俗了,其实质就是一种布尔值(true和false)不过另有区别,分为下面三种:

成功(Success)

失败(Failure)

运作中(Running)

根据他们的名字就非常好理解了,子节点会向其父节点返回前两种(Success)和失败(Failure)状态,告诉父节点其结果是成功还是失败。

第三种(Running)则有点特殊,他会让子节点始终保持这一状态。

了解了工作流之后,我们可以进行行为树各个部分的学习了。

1.2行为树的组成部分

首先我们来看一个比较完整的行为树

现在我们肯定是不能理解行为树里的内容具体是啥的,所以我们不妨一个一个来进行理解。

1.2.1根节点(Root)

 根节点只有子节点没有父节点,行为树的每一次启用都从根节点开始。

1.2.2叶节点(Leaf)

和根节点(Root) 相反,叶节点只有父节点,没有子节点

叶节点里是所需要执行的命令。

1.2.3复合节点之顺序节点(Sequence)

顺序节点可以用于决定多个叶节点之间的执行顺序。 

比如我们有这个顺序节点及其子节点

可以看到两个子节点的执行会需要一定顺序,而这个顺序受到顺序节点的控制,当他进行工作时,他的运作是这样的。

次序节点 ->走到门旁边(成功)->次序节点->打开门(成功)->次序节点

如果中途某一个动作因为某些原因失败了,那么就会直接返回给顺序节点Failure,后续动作都不会执行,而是其进行其他动作。

1.2.4复合节点之选择节点(Selector)

选择节点会对当前游戏状态进行一个评定 ,评定后选择接下来所需要进行的子节点。

只有当所有子节点都返回Failure时,选择节点才会返回Failure,否则若有返回Success的子节点,选择节点就会返回Success

比如我们有这个选择节点及其子节点

 我们试图利用这样的逻辑来设计一个会主动抓住玩家,并在无法抓住玩家时自爆的敌人行为AI。

在这套行为树下,敌人会先试图直接抓住玩家,如果成功了,那么其他的什么跑向玩家和自爆就没有执行的必要;

如果敌人无法直接抓住玩家,那么选择节点就会执行顺序节点里的内容;如果最后顺序节点都返回失败(注意:顺序节点中只要子节点有一项返回失败,顺序节点就会返回失败),那么选择节点就会执行到自爆,可怜的敌人结束了自己的生命。

当然,如果自爆都无法做到……那是后话了。或许我们可以再为他添加一个父节点或子节点来解决这个问题。

1.2.5随机复合节点(Random Nodes)

也就是随机顺序节点或随机选择节点。这两种节点的运作模式与他们的非随机版本一模一样,除了其在选择执行的叶节点上随机。

在此不再赘述。

1.2.6装饰节点(Decorator Nodes)

装饰节点非常特殊……

好吧好吧,行为树里有什么东西不特殊吗?那请问装饰节点到底是干啥的捏?

您先别急,装饰节点是一个大类,我们分开来看。

1.2.6.1逆变节点(Interver)

逆变,顾名思义,他会把叶节点返回的值倒置。

比如说叶节点千辛万苦返回了成功,然后逆变节点就会说:哦哦,你成功了啊,很不错!然后报告失败给他的父节点。

1.2.6.2成功节点(Succeeder)

成功节点比较官僚主义,不管他的下属叶节点返回失败还是成功,他都会返回成功这一结果给他的上级父节点。赢!

1.2.6.3重复节点(Repeat)

顾名思义,重复节点会在子节点返回结果后决定是否反复执行他。通常我们会把重复节点放在一个树的最顶部用来保证行为树会不断运行。

而且还可以设定重复节点的重复次数。

1.2.6.4重复至失败节点(Repeat until Fail)

和上面的重复节点类似,不同的是他会在子节点返回Failure时给他的父节点返回Failure,这是个实话实说的好官呢!

很好,我们虽然还是不清楚整个行为树的逻辑,但我们起码已经知道他的每一个节点是用来干嘛的了。

2.0行为树的执行

2.1行为树的遍历(tick)

在行为树刚被发明出来时,行为树会在每一帧都进行一次遍历,也就是从根节点开始,逐层检测子节点的活跃状态,最后根据叶节点的活跃状态决定其执行内容。

这样的一个逐层过程叫做一个tick

2.2定义叶节点

尽管我们已经拥有了一颗行为树,我们也还是不能让这颗行为树运作起来——我们还没有告诉行为树的叶节点他的任务呢!所以为了让行为树运作起来,我们必须定义每一个叶节点的功能。

绝大多数的行为树都会包含以下两个功能。

初始化,执行。

2.3.1初始化(Init)

这个可以参考Unity中的Start()方法,其功能有些相似。

在这个节点首次被其父节点访问时,初始化程序就会被调用,在父节点的所有子节点都完成流程之前,初始化程序都不会再调用。

通过初始化,节点可以获取各种参数用于功能的执行。

你看,是不是很像Start()方法。

2.3.2执行(Process)

和初始化不同的是,执行方法会在这个节点每一次被访问时调用(每一个tick都会调用)。

比如如果某个节点不断返回Running的结果,那么这个节点就会不断的进行Process,节点的功能会持续执行直到Success或者Failure。

除了这两种功能上的定义需求以外,参数的传入也很重要

2.3.3参数传入

和定义方法的传入数据一样,传入类似于Charactor Tag,Velocity这样的数据用于节点的执行,在此不再赘述。

好的,我们已经知道了行为树的组成成分和行为树的执行原理,但是我们要如何搭建一个行为树呢?用代码撸一个吗?能不能像图示一样可视化操作呢?

当然是能!接下来,我们来了解下什么叫黑板(Blackboard)

3.0黑板(Blackboard)

并非所有行为树都有黑板?我也不懂人工智能,所以我不是很清楚,但我这里的黑板指的是UE或者Unity中Behaviour Tree的黑板

3.1黑板(Blackboard)是什么

这个黑板与我们上高数课和线代课的哪个黑板不同。

在上文中,我们介绍了行为树的参数传入,我们说他的传输模式类似于方法的参数传入,其实这样说并不准确。

我们可以这样想,如果我们有一个比较庞大的行为树,那么他的数据传入借口是否有些过多过于复杂了?所以我们就要思考,能不能用一个结构体或者什么其他的从行为树的各个模块中提取数据出来,然后实现行为树之间的数据共享?

于是,黑板(Blackboard)来了。

我们可以用一个黑板从行为树的各个模块中获取各个参数,然后又反过来为行为树提供所需参数。这样就能实现行为树的参数获取和行为树中各个模块之间的数据通信了!

以上便是我对于游戏AI行为树运作原理的理解了,具体的使用方法要分不同的行为树插件而定(README里通常会有介绍)

如果有什么地方理解有误的话,欢迎指出!

有关AI行为树的基础运作原理的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. postman接口测试工具-基础使用教程 - 2

    1.postman介绍Postman一款非常流行的API调试工具。其实,开发人员用的更多。因为测试人员做接口测试会有更多选择,例如Jmeter、soapUI等。不过,对于开发过程中去调试接口,Postman确实足够的简单方便,而且功能强大。2.下载安装官网地址:https://www.postman.com/下载完成后双击安装吧,安装过程极其简单,无需任何操作3.使用教程这里以百度为例,工具使用简单,填写URL地址即可发送请求,在下方查看响应结果和响应状态码常用方法都有支持请求方法:getpostputdeleteGet、Post、Put与Delete的作用get:请求方法一般是用于数据查询,

  3. 软件测试基础 - 2

    Ⅰ软件测试基础一、软件测试基础理论1、软件测试的必要性所有的产品或者服务上线都需要测试2、测试的发展过程3、什么是软件测试找bug,发现缺陷4、测试的定义使用人工或自动的手段来运行或者测试某个系统的过程。目的在于检测它是否满足规定的需求。弄清预期结果和实际结果的差别。5、测试的目的以最小的人力、物力和时间找出软件中潜在的错误和缺陷6、测试的原则28原则:20%的主要功能要重点测(eg:支付宝的支付功能,其他功能都是次要的)80%的错误存在于20%的代码中7、测试标准8、测试的基本要求功能测试性能测试安全性测试兼容性测试易用性测试外观界面测试可靠性测试二、质量模型衡量一个优秀软件的维度①功能性功

  4. ES基础入门 - 2

    ES一、简介1、ElasticStackES技术栈:ElasticSearch:存数据+搜索;QL;Kibana:Web可视化平台,分析。LogStash:日志收集,Log4j:产生日志;log.info(xxx)。。。。使用场景:metrics:指标监控…2、基本概念Index(索引)动词:保存(插入)名词:类似MySQL数据库,给数据Type(类型)已废弃,以前类似MySQL的表现在用索引对数据分类Document(文档)真正要保存的一个JSON数据{name:"tcx"}二、入门实战{"name":"DESKTOP-1TSVGKG","cluster_name":"elasticsear

  5. ruby - Ruby gsub 替换中的行为不一致? - 2

    两个gsub产生不同的结果。谁能解释一下为什么?代码也可在https://gist.github.com/franklsf95/6c0f8938f28706b5644d获得.ver=9999str="\tCFBundleDevelopmentRegion\n\ten\n\tCFBundleVersion\n\t0.1.190\n\tAppID\n\t000000000000000"putsstr.gsub/(CFBundleVersion\n\t.*\.).*()/,"#{$1}#{ver}#{$2}"puts'--------'putsstr.gsub/(CFBundleVersio

  6. ruby-on-rails - Ruby 中意外的大小写行为 - 2

    我在一段非常简单的代码(如我所想)中得到了一个错误的值:org=4caseorgwhenorg=4val='H'endputsval=>nil请不要生气,我希望我错过了一些非常明显的东西,但我真的想不通。谢谢。 最佳答案 这是典型的Ruby错误。case有两种被调用的方法,一种是你传递一个东西作为分支的基础,另一种是你不传递的东西。如果您确实在case中指定了一个表达式语句然后评估所有其他条件并与===进行比较.在这种情况下org评估为false和org===false显然不是真的。所有其他情况也是如此,它们要么是真的,要么是假的。

  7. ruby - 使对象的行为类似于 ruby​​ 中并行分配的数组 - 2

    假设您在Ruby中执行此操作:ar=[1,2]x,y=ar然后,x==1和y==2。是否有一种方法可以在我自己的类中定义,从而产生相同的效果?例如rb=AllYourCode.newx,y=rb到目前为止,对于这样的赋值,我所能做的就是使x==rb和y=nil。Python有这样一个特性:>>>classFoo:...def__iter__(self):...returniter([1,2])...>>>x,y=Foo()>>>x1>>>y2 最佳答案 是的。定义#to_ary。这将使您的对象被视为要分配的数组。irb>o=Obje

  8. ruby - 了解在 Ruby 中与 lambda 一起使用的 inject 行为 - 2

    我经常将预配置的lambda插入可枚举的方法中,例如“map”、“select”等。但是“注入(inject)”的行为似乎有所不同。例如与mult4=lambda{|item|item*4}然后(5..10).map&mult4给我[20,24,28,32,36,40]但是,如果我制作一个2参数lambda用于像这样的注入(inject),multL=lambda{|product,n|product*n}我想说(5..10).inject(2)&multL因为“inject”有一个可选的单个初始值参数,但这给了我......irb(main):027:0>(5..10).inject

  9. ruby-on-rails - 在 Rails 中需要整个目录树的好方法是什么? - 2

    我正在使用Rails3.2.2并希望递归加载某个目录中的所有代码。例如:[Railsroot]/lib/my_lib/my_lib.rb[Railsroot]/lib/my_lib/subdir/support_file_00.rb[Railsroot]/lib/my_lib/subdir/support_file_01.rb...基于谷歌搜索,我试过:config.autoload_paths+=["#{Rails.root.to_s}/lib/my_lib/**"]config.autoload_paths+=["#{Rails.root.to_s}/lib/my_lib/**/"

  10. ruby - 嵌套 yield 如何运作? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭9年前。Improvethisquestion我想写一段代码满足:SomeClass.new.execute(method)==3我有:classSomeClassdefexecute(method)defmethodyieldendendendmethod=1+2这给了我nil。我对yield仍然很困惑。非常感谢任何帮助。

随机推荐