欢迎光临散文网 会员登陆 & 注册

Minecraft 生物 AI 机制详解(一)

2023-01-13 13:42 作者:Nickid2018  | 我要投稿

在 MC 中生物有多种多样的行为,这些行为都是通过其 AI 系统计算得到的。这篇专栏就来讲一讲生物 AI 是如何计算的。

一. 生物 AI 的分类

目前 MC 中拥有两套独立的 AI 计算系统,每个生物有且仅有一种计算系统。为了方便讲述,我们在这里将这两种系统取名为目标系统(Goal System)和记忆行为系统(Memory-Behavior System),这两套系统除了寻路系统、基础探测系统和控制器外使用的代码完全不同。

目前使用记忆行为系统的生物只有村民、猪灵、猪灵蛮兵、疣猪兽、僵尸疣猪兽、山羊、悦灵、青蛙、蝌蚪、监守者和骆驼,剩余的所有生物都使用目标系统。目标系统能实现的行为比较少,而记忆行为系统实现的行为可以更加复杂,可以实现生物不同“活动”状态和实现村民的时间表。

二. 控制器

生物的 AI 系统有两种,而控制器只用了一套系统。生物能做到的基础动作都由控制器决定,由控制器控制的动作都会按照时间平滑地运行,而不是让生物立刻到达 AI 行为想到达的状态。生物的控制器具体有:

  • LookControl:视线控制器。可以输入一个向量或一个实体作为看向的位置。如果看向了生物,就会选择去看生物的眼睛,否则看实体的中心。

  • MoveControl:移动控制器。移动有四个状态,分别是 WAIT(等待)、MOVE_TO(移动到指定位置)、STRAFE(沿指定方向行进)和 JUMPING(跳跃)。AI 系统通常不是直接调用这个控制器,而是使用寻路系统调用 MOVE_TO 从而间接调用这个控制器。移动控制器每刻计算一次,执行移动后立刻设置为等待状态(除非在移动到目标位置时需要跳跃,进入跳跃状态),并等待下一刻寻路系统的指令。

  • JumpControl:跳跃控制器。这个控制器也可以被移动控制器在决定跳跃时调用。

  • BodyRotationControl:旋转控制器。主要用于头和身体的旋转,用于平衡视线控制器和移动控制器对生物的旋转方向。AI 系统不会对这个控制器直接调用,下文也不会涉及这个。

控制器可以被继承并被增加行为,比如说继承于 MoveControl 的 FlyingMoveControl,它的内部是可飞行生物的移动控制器。在下文中我们基本上只使用各个控制器的基类作为说明。

三. 探测系统

生物可以探测到其他实体的存在。探测实体是否在视线内并记录的类就是 Sensing(不是 Sensor,Sensor 是记忆行为系统使用的)。而检查生物是否能看到某个实体的具体方法位于 Mob 类的 hasLineOfSight,具体代码如下:

从这段代码中,我们可以看出生物能被探测到的实体需要符合下面的条件:

  • 生物与能被探测到的实体在同一个维度。

  • 生物与能被探测到的实体距离 128 格以内。

  • 生物与能被探测到的实体的眼睛连线中间不能被方块的碰撞箱阻挡。

这个视角下苦力怕的视线会被栅栏的 1.5 格高的碰撞箱阻挡导致看不到玩家

在 1.17 Pre-release 1 之前,这个方法并没有前两条限制,导致了 MC-202249 这个漏洞。具体办法就是:在可愤怒生物针对你的时候传送到别的维度,如果坐标太大会导致尝试加载大量的区块做碰撞计算,进而使服务器崩溃。

生物根据方块的碰撞箱计算是否能看到别的实体,所以生物是不能通过玻璃等透明方块“看到”玩家的。

除了视线判定外,生物探测还需要检查生物与实体之间的距离,这部分代码是 TargetingConditions 负责的,它的重要代码如下:

从这段代码中,我们可以看出:

  • 生物不会探测自己。

  • 如果被探测实体不能被任何生物看到,生物就没法探测这个实体。只有三种情况满足这个条件:玩家处于旁观者模式、实体已经死亡和当作标记的盔甲架。

  • 如果这个探测条件用于攻击,而被探测实体不会被视作攻击对象或世界处于和平难度,那么生物无法探测到这个实体。实体不会被视作攻击对象有两种情况:实体是无敌的和美西螈正在装死。

  • 如果这个探测条件用于攻击,而生物本身不会攻击被探测实体或与被探测实体属于同一队,就不会探测到这个实体。

  • 如果生物具有探测范围,则检查被探测实体是否在探测范围内。根据被探测实体的状态效果、盔甲数量和是否潜行判断最小探测范围的半径,此半径不会小于 2,详细的算法可以参考 Wiki。有些生物在某些情况下不会因为被探测实体的状态而缩小探测半径,比如唤魔者在探测周围恼鬼时就不会因为恼鬼隐身而受到影响。

  • 如果生物要求看到被探测实体但实际上没有看到,探测也会失败。有些生物在某些情况下不要求看到被探测实体,比如说生物遭受被探测实体攻击时就不需要看到那个实体。

探测系统在很多方面都有应用,比如生物攻击对象的选择,寻找可以繁殖的生物等。

四. 目标系统

现在我们来介绍第一套 AI 系统,目标系统。它主要由两个重要组件构成:Goal 和 GoalSelector。

1. Goal

目标系统中的目标指的就是这个类和它的子类,它定义了实体的某一项具体动作。它含有的方法如下:

  • canUse:这个目标是否可以执行,也就是对应生物某项动作的前提。

  • start:目标开始执行时需要运行的代码。

  • requiresUpdateEveryTick:这个目标在执行时是不是需要每刻都要运行。如果不是,每 2 刻才会执行一次 tick。

  • tick:目标正在执行时运行的代码。

  • isInterruptable:这个目标执行过程中是否可以被打断并被其他更高优先级的目标替代。

  • canContinueToUse:这个目标是否仍然满足条件,通常情况下和 canUse 是一样的。

  • stop:目标停止时需要运行的代码,停止包括条件不满足而停止和被替代而停止。

每个 Goal 都有一个标志集合,代表它们要占用什么控制器。标志有 4 种:MOVE(占用移动控制器)、LOOK(占用视线控制器)、JUMP(占用跳跃控制器)和 TARGET(占用攻击对象)。有些目标不占用任何标志,这些目标会在条件满足时执行,条件不满足时停止。

TARGET 是比较特殊的标志,使用这个标志的 Goal 都继承于 TargetGoal,这是一类用于选择攻击对象的目标,而不是用来描述动作的。TargetGoal 都包含两个属性,是否需要看到和是否需要到达。如果一个 TargetGoal 要求看到而实体在视线中超过了一定时间,那么它就不会继续运作。如果 TargetGoal 要求到达而生物与实体间没有可到达的路径,它的条件也就不满足而终止。

2. GoalSelector

下面就要介绍目标系统最重要的部分:目标选择器。

生物可以有多种目标,而在某一时刻使用的目标并没有那么多,并且目标之间是有冲突的,所以需要一个调配目标的代码。目标选择器就充当着这个作用,它的具体原理是这样的:

首先,每个目标都有它们的优先级,数字越小,优先级就越高。下面使用史莱姆的 AI 做简单介绍:

registerGoals 是生物注册目标使用的方法。这个方法内分别给 goalSelector 和 targetSelector 注册了不同的目标,而这两个选择器没有什么本质上的区别,都是 GoalSelector,只是为了在时间上分开这两种计算。GoalSelector 的 addGoal 方法第一个参数是优先级,第二个是对应的目标。按照这部分代码的描述,可以做出下方的表格:

不同目标的优先级

按照不同目标的标志,我们可以将这个表继续拆开细化:

根据不同标志拆开目标的优先级,每个相同名字的目标都是一个对象,而不是拆成了多个

目标选择器每 2 刻更新一次,会选择每个标志优先级最高可执行目标作为执行的目标,如果一个标志中的所有目标都不满足它们自身的条件,那么这个标志内就没有目标要执行。如果有一个目标满足条件且优先级高于所有目前它所占用标志中正在活跃的目标,则会尝试打断每个标志中优先级低的目标并设置这个新的目标为对应标志的活跃目标。如果一个标志被禁用,那么使用这个标志的所有目标都不能执行。

下面使用史莱姆的 AI 进行举例。假设史莱姆不在水中且没有攻击对象,那么 SlimeFloat 和 SlimeAttack 的条件就不满足,而 SlimeKeepOnJumping 和 SlimeRandomDirection 条件满足,TARGET 标志中没有条件被满足,所以没有活跃的目标。

如果此时史莱姆进入了水中,这时 SlimeFloat 的条件满足,打断 SlimeKeepOnJumping 并调用其 stop 方法,并将活跃目标设置为 SlimeFloat。

如果史莱姆察觉到了铁傀儡,那么优先级为 3 的 NearestAttackable 目标条件满足,并将史莱姆的攻击对象设置为铁傀儡。在下一次 AI 计算时,SlimeAttack 因为有攻击对象而满足条件,SlimeRandomDirection 不满足条件被停止,所以 LOOK 标志中 SlimeAttack 成为活跃目标。

这个例子非常的简单,因为史莱姆是 AI 最简单的生物之一。接下来我们换一个生物进行讨论,使用 AI 比较复杂的僵尸。

僵尸目标表

从这张表中我们可以看到目标可以有相同的优先级,有相同优先级的目标不会互相覆盖,在同时满足条件时将选择随机的一个(主要和哈希码大小有关)。

以僵尸攻击和踩海龟蛋的目标举例。如果僵尸没有攻击对象且发现了海龟蛋,此时 ZombieAttackTurtleEgg 满足条件运行。在 LOOK 标志中,RandomLookAround 不能执行,因为在 MOVE 标志中它的优先级低于正在活跃的目标,如果要选择执行只能选择 LookAtPlayer。这代表了僵尸在踩海龟蛋时只有可能看向行进方向或看向玩家,而不可能随机旋转头去看别的地方。

这时僵尸发现了生存模式下的玩家,优先级为 2 的 NearestAttackable 满足条件,将玩家作为攻击对象置位。在下一次 AI 计算时,ZombieAttack 的条件满足,打断 ZombieAttackTurtleEgg。此时 JUMP 标志上没有活跃的目标,因为在 MOVE 标志中 ZombieAttackTurtleEgg 优先级低于 ZombieAttack,不能进行替代,所以这个目标也无法启动。在表面上来看,就是僵尸放弃去踩海龟蛋而是去追赶玩家。

从 TARGET 的优先级排序可以看出,僵尸攻击对象的排序是:攻击僵尸的实体 > 玩家 > 村民或流浪商人 = 铁傀儡 > 小海龟。这个排序导致了怪物间可以内讧:如果一只骷髅击中了僵尸,那么僵尸的攻击对象就从其他实体转移到攻击它的骷髅身上,引发怪物间的攻击。

通过对这张表的分析,我们也可以看出僵尸不会自发的躲避太阳,它的寻路不会考虑路径上是否会被太阳照到。

下面让我们讲一下目标系统最后一个例子,这次我们使用骷髅分析。

骷髅的目标表

骷髅含有一个无标志的目标,这个目标决定骷髅是否躲避阳光,也就是使寻路系统尽量不走向露天的地方。当白天且骷髅没有头部盔甲时此目标条件满足被执行,在夜晚或骷髅头部穿戴上盔甲时条件不满足停止。

骷髅的 AI 有一个特殊的地方,它的攻击与手上的物品有关。当手上拿弓时使用 RangedBowAttack 目标,这使得骷髅可以用弓射击;如果手上没有拿弓,就会使用 MeleeAttack 目标进行近战攻击。

由于 FleeSun 目标(白天躲到不会使自己着火的位置)和 AvoidEntity 目标(躲避狼)处于同一优先级,所以这两个目标互斥,只有其中一个目标结束后另一个目标才可能执行。这就导致了骷髅在躲避狼的时候不会主动找不会使自己着火的位置防止燃烧,而在走向不会使自己着火的位置时不会主动躲避狼。并且由于这两项目标优先级高于两个攻击目标,所以骷髅即使被狼伤害并将狼视为攻击对象,也很少能向狼发起攻击,除非骷髅已经离狼足够远并不用担心被太阳照到时骷髅才会开始攻击狼。

五. 常见目标

在下面我们列出了一些常见的目标,有很多生物都使用了这些目标。

1. RandomStrollGoal 随机游走

许多生物在没有攻击对象的情况下会进行随机的走动,而实现这个随机走动的就是这个目标和它的子类。

这个目标的执行条件是:生物不是坐骑,绝大多数生物还要求随机走动与上次进行动作要间隔 100 刻以上。在开始执行这个目标时会计算一个随机的位置作为游走终点,如果这个终点不存在这个目标也会执行失败。接下来将寻路系统设置到指定的终点上,操纵生物向终点前进,就达到了随机游走的效果。目标结束有三种情况:生物已经到达终点、生物无法到达终点但走到了离得最近的位置或生物成为了坐骑。由于生物寻路系统会进行路径的更新,所以即使路径被破坏,寻路系统也会找到一个新的路径走动,除非寻路系统找不到任何可行路径到达终点,这时寻路系统会找到一个离终点最近的路径,尝试到达这个最近的位置。

生物随机游走

随机游走的终点虽然是随机选取的,但是也有一定的规律。

生物使用上面的代码生成一个随机的终点。这段代码中,radius 是水平搜索半径,使用切比雪夫距离;vertical 是垂直搜索半径。通过这两个值可以计算出生物走的方向 direction。restricted 代表生物是否被限制了行动,比如被拴绳拴住时生物就不能自发离开拴绳位置5格远。这些参数再传入到下面的方法中判断这个位置是否是一个可以使用的位置。

这段代码先将方向和生物的位置综合计算出生物要到达的位置,然后检查这个位置是否满足条件:

  • 如果位置超出了世界的建筑高度,此位置无效。

  • 如果生物被限制行动并且位置超过了限制半径,此位置无效。

  • 如果位置的下方方块是不稳定的,此位置无效。稳定的定义是:方块可以遮挡光线并且遮挡包围盒(Occlusion Shape)是完整的方块大小。默认情况下遮挡包围盒是和方块的轮廓箱是一样的,只有几个特殊方块(滴水石锥、细雪、栅栏等)和轮廓箱不一样。举个例子,单层台阶就不是稳定方块,无论是上半台阶还是下半台阶,都属于不稳定方块,因为这两种方块的遮挡包围盒都不是完整的;而双层台阶就是稳定方块,因为轮廓箱和遮挡包围盒都是完整的方块。玻璃也不是稳定方块,因为它不会遮挡光线。

  • 如果位置的下方方块对于生物的寻路代价不为 0,此位置无效。

生物会计算出来 10 个位置(无论有效还是无效),找出有最大行走目标值的有效位置作为随机游走的终点。如果所有位置都无效,那么生物就不会随机游走了。

有些子类并不完全使用这种方法计算终点,比如说 WaterAvoidingRandomStrollGoal 使用了 LandRandomPos.getPos,它与上面算法主要区别是尽量避免走到水里面,并且将随机游走终点垂直向上移动到表面上,其他部分大致相同。对于所有随机游走终点,上面 4 条对于终点的限制都是存在的。

利用上面的原理,我们可以完全的阻止生物的随机游走。以在地面的僵尸举例,它的水平搜索半径是 10,垂直搜索半径为 7,假如我们搞一个21x21的区域放上任意一种不稳定方块,比如上半台阶或者玻璃,把僵尸放到中央,垂直范围 7 格内没有别的方块,这样即使这个平台周围被泥土之类的方块包围,僵尸也不会尝试随机游走到那里。

僵尸在此处不会随机游走,测试了 4x72000 刻

同时根据这个原理,我们也可以利用怪物的随机游走让它们走到我们想让他们走的地方。(当然这个速度是很慢的)

苦力怕随机游走只能走到这个泥土的位置上,相当于控制了苦力怕的行动

使用这种办法控制生物行动的刷怪塔就是游走刷怪塔。它的生成平台使用上半台阶,引诱平台需要使用完整方块并破坏生成位置,在生成平台和引诱平台中间加活版门欺骗寻路让怪物掉下去。引诱平台越大,随机游走寻找到有效的终点的概率就越大,进行随机游走的次数就会增加,效率也会增加。生成平台不能无限扩大,由于绝大多数怪物不在水中时水平搜索半径都是 10,所以生成平台最远处与引诱平台不能超过 8 到 9 格,否则会堆积怪物。同样的,这种刷怪塔垂直 7 格半径之内除引诱平台不能有任何完整方块,否则随机游走可能会使用其他的位置作为终点。

2. MeleeAttackGoal 近战攻击

许多怪物都有近战攻击的目标,用于攻击玩家或是其他生物,而实现近战攻击的就是这个类和它的子类。

近战攻击目标每 20 刻检查一次是否满足条件。当生物没有攻击对象或攻击对象已死亡时,不会进行近战攻击。如果生物与攻击对象距离小于生物的攻击距离,或生物找到了一条路径可以通向攻击对象时,近战攻击目标满足条件。

这个目标可以持续运作的条件和开始条件不一样。持续运作仍然要求攻击对象存在并存活,但是如果攻击对象超出了生物的限制活动范围,那么近战攻击目标就不能继续运作。绝大多数生物都要求看到攻击对象,如果寻路已经停止,那么近战攻击也就结束了。如果攻击对象变为旁观模式或创造模式,近战攻击目标也会终止。

近战攻击目标每刻都会执行一次,检查攻击对象是否存在,并将头转向攻击对象的位置。为防止跟丢攻击对象,系统会尝试重新计算路径终点。重新计算路径终点的条件是:

  • 重新计算路径倒计时 ticksUntilNextPathRecalculation 为 0。

  • 如果生物需要看到攻击对象,则只能在能看到攻击对象的时候重新计算终点。

  • 上次计算的位置和现在攻击对象的位置距离大于 1 或有 5% 的概率忽略距离重新计算。

重计算路径倒计时与生物和攻击对象的距离有关。倒计时基础时长在 4 到 10 刻,如果距离大于 32 格时长会加 10 刻,如果大于 16 加 5 刻,如果重新计算路径后发现无法到达攻击对象位置会再加 15 刻时间。也就是说,重计算倒计时最少 4 刻,最多 30 刻。

生物攻击也有冷却时间,为 20 刻。如果生物与攻击对象的距离小于生物的攻击距离,且冷却为 0,生物就可以近战攻击。

3. NearestAttackableTargetGoal 选择最近可攻击对象

这个目标是用来选择攻击对象的,许多怪物都使用这个类检查周围的生物。

它有 5 个参数:攻击对象的类型、平均检查时间、是否必须看到、是否必须达到和一个检查实体的谓词。攻击对象的类型可以为玩家也可以为其他实体,平均检查时间用于判断是否进行检查。

攻击对象检查的间隔不是固定的,平均值是 randomInterval。如果找不到一个满足条件的实体,那么这个目标条件不满足不会执行。

寻找攻击对象的范围由生物的跟随距离决定,跟随距离越大,查找范围也就越大。对于非玩家实体,查找范围为生物碰撞箱水平扩展跟随距离,上下扩展 4 格。也就是说,如果非玩家实体在生物头上四格以上,生物就无法将这个实体认定为攻击对象。而玩家的查找范围是跟随距离为半径的球,垂直方向上超出跟随距离才能避免被生物探测到。

本专栏使用的源代码为 1.19.3 Mojang Mapping。

反编译器 CFR 0.152,反混淆器 Nickid2018/GitMCDecomp。

有错误可以在评论区指出。

Minecraft 生物 AI 机制详解(一)的评论 (共 条)

分享到微博请遵守国家法律