Minecraft中实体AI的一些研究

前几天接了一个老板的单子,要求我让玩家以僵尸的生物类型去进行游戏。这个插件有一些东西是需要通过nms来实现的,比如:让僵尸跟随玩家走。所以我花了许多时间研究了好一会儿Minecraft的代码,在这里把自己的一些经验分享出来。
真没想到有朝一日我也会在一堆abcdefg的混淆代码里研究出点东西来,果然钱真的是万能的吗?

开始

这篇文章内的所有代码都是在Spigot 1.8.8 r3环境下编译
首先讲一下这位老板的一些涉及到NMS的要求:

  • 小白、蜘蛛不会攻击玩家但玩家诺攻击他们则也会攻击玩家
  • 僵尸不再攻击玩家
  • 诺周围有野生僵尸则这些僵尸会跟着玩家走
  • 僵尸会攻击玩家攻击的目标与攻击玩家的目标(但不会攻击其他玩家

首先开始做第一点,其实要做起来也是很简单的(甚至你可以不用碰nms,直接用BukkitAPI就能实现),我这里选择用nms来实现。
大家都知道,原版的僵尸、骷髅、蜘蛛都是会主动攻击玩家的(蜘蛛只在亮度低于阈值时攻击),这是原版生物固定的AI,外部无法更改
我的思路就是,自己写一个Spider、Skeleton、Zombie,然后覆盖掉原版的这三个生物,当Minecraft生成这些生物时,他们就会被应用上我想要的AI,最终达到我的目的。

我首先查看了Minecraft里EntitySpider的代码,在这些毫无根据的变量和方法名中乱逛是会迷路的,因此我只关注AI的那一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public EntitySpider(World world) {
super(world);
this.setSize(1.4F, 0.9F);
this.goalSelector.a(1, new PathfinderGoalFloat(this));
this.goalSelector.a(3, new PathfinderGoalLeapAtTarget(this, 0.4F));
this.goalSelector.a(4, new EntitySpider.PathfinderGoalSpiderMeleeAttack(this, EntityHuman.class));
this.goalSelector.a(4, new EntitySpider.PathfinderGoalSpiderMeleeAttack(this, EntityIronGolem.class));
this.goalSelector.a(5, new PathfinderGoalRandomStroll(this, 0.8D));
this.goalSelector.a(6, new PathfinderGoalLookAtPlayer(this, EntityHuman.class, 8.0F));
this.goalSelector.a(6, new PathfinderGoalRandomLookaround(this));
this.targetSelector.a(1, new PathfinderGoalHurtByTarget(this, false, new Class[0]));
this.targetSelector.a(2, new EntitySpider.PathfinderGoalSpiderNearestAttackableTarget(this, EntityHuman.class));
this.targetSelector.a(3, new EntitySpider.PathfinderGoalSpiderNearestAttackableTarget(this, EntityIronGolem.class));
}
static class PathfinderGoalSpiderMeleeAttack extends PathfinderGoalMeleeAttack {
public PathfinderGoalSpiderMeleeAttack(EntitySpider entityspider, Class<? extends Entity> oclass) {
super(entityspider, oclass, 1.0D, true);
}

public boolean b() {
float f = this.b.c(1.0F);
if (f >= 0.5F && this.b.bc().nextInt(100) == 0) {
this.b.setGoalTarget((EntityLiving)null);
return false;
} else {
return super.b();
}
}

protected double a(EntityLiving entityliving) {
return (double)(4.0F + entityliving.width);
}
}

static class PathfinderGoalSpiderNearestAttackableTarget<T extends EntityLiving> extends PathfinderGoalNearestAttackableTarget {
public PathfinderGoalSpiderNearestAttackableTarget(EntitySpider entityspider, Class<T> oclass) {
super(entityspider, oclass, true);
}

public boolean a() {
float f = this.e.c(1.0F);
return f >= 0.5F ? false : super.a();
}
}

初次见面,一眼看去眼里尽是abcd,不知道在干些啥。不过好歹goalSelectortargetSelector没有被混淆。Selector顾名思义就是选择器的意思,那么这两个属性一个是Goal选择器,一个是Target选择器。由这两个选择器就构成了整个Spider(其他所有生物也是)的AI。
一路向上寻找,最终发现这两个字段被定义在父类EntityInsentient中,且被设为公开字段。即是说,该字段允许被任何代码修改。(所以上面划了删除线,我们完全可以在原版Spider出生时获取并修改它的AI,只需要监听EntitySpawnEvent即可)
同时,看着这一大段代码,我们也能大胆的猜测this.targetSelector.a(优先级, xxxxx);这一段代码可以用来添加新的AI,那么我们也可以照着抄下来,然后把不需要的AI删掉。
Minecraft里的Spider行为十分复杂,我并不想完全重写这所有的东西,仅仅只是想删除它会在晚上攻击玩家的AI而已。所以我新建了一个类,并继承了EntitySpider,这样我的类能够继承所有原版蜘蛛的属性和行为。然后我在super父类的构造方法之后,开始对AI动手。我倒是很想直接删掉那单独的一个不需要的AI,但是我面对着这些混淆后的abcd,着实有点懵逼。因此决定直接重新构建一个Selector。
我需要知道如何拿到一个空的AI选择器,但我又不想去看Selector的构造器,我害怕再次面对一堆abcd或者其他什么东西。因此我向上寻找EntitySpider的父类,想看看这两个Selector在何处被定义,之后我只要把这段代码复制下来即可,最终我在EntityInsentient类中找到它。

1
2
this.goalSelector = new PathfinderGoalSelector(world != null && world.methodProfiler != null ? world.methodProfiler : null);
this.targetSelector = new PathfinderGoalSelector(world != null && world.methodProfiler != null ? world.methodProfiler : null);

抄代码总是如此便捷,而且我抄的还是官方代码。
总之,我是拿到了一个空的Selector,然后把上面那一大堆代码粘贴上去。这样我的AI就和原版一模一样了。然后我注释掉了this.targetSelector.a(2, new EntitySpider.PathfinderGoalSpiderNearestAttackableTarget(this, EntityHuman.class));这一行,只要稍微懂一点英语的人就会知道,这行代码的意思是给蜘蛛增加一个近战攻击的AI,目标为玩家。

那么,既然Spider写完了,Skeleton也是依葫芦画瓢。抄完代码后,把this.targetSelector.a(2, new PathfinderGoalNearestAttackableTarget<>(this, EntityHuman.class, true));注释掉。
但是当我再回头看看Skeleton AI部分的代码时,我发现了一些东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
private PathfinderGoalArrowAttack a = new PathfinderGoalArrowAttack(this, 1.0D, 20, 60, 15.0F);
private PathfinderGoalMeleeAttack b = new PathfinderGoalMeleeAttack(this, EntityHuman.class, 1.2D, false);
public void n() {
this.goalSelector.a(this.b);
this.goalSelector.a(this.a);
ItemStack itemstack = this.bA();
if (itemstack != null && itemstack.getItem() == Items.BOW) {
this.goalSelector.a(4, this.a);
} else {
this.goalSelector.a(4, this.b);
}

}

我发现了其中有两行没有写AI优先级:this.goalSelector.a(b);this.goalSelector.a(a);,我看了看这个被调用的方法的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void a(PathfinderGoal pathfindergoal) {
Iterator iterator = this.b.iterator();

while(iterator.hasNext()) {
PathfinderGoalSelector.PathfinderGoalSelectorItem pathfindergoalselector_pathfindergoalselectoritem = (PathfinderGoalSelector.PathfinderGoalSelectorItem)iterator.next();
PathfinderGoal pathfindergoal1 = pathfindergoalselector_pathfindergoalselectoritem.a;
if (pathfindergoal1 == pathfindergoal) {
if (this.c.contains(pathfindergoalselector_pathfindergoalselectoritem)) {
pathfindergoal1.d();
this.c.remove(pathfindergoalselector_pathfindergoalselectoritem);
}

iterator.remove();
}
}

}

好吧,我想我找到了删除一个AI的办法了。但这发生的太迟了 —— 我已经删完了我想删的AI!
然而你以为我为此浪费了时间吗?其实并没有!就在我刚刚准备回去写“如何通过命令监听器实现我想要的效果”时,我发现……spider的AI删不了!仔细观察上面Spider的代码,我们可以发现,PathfinderGoalSpiderNearestAttackableTarget这个类是被定义在Spider内,而且不是公共类,因此我们无法访问。但这并不是主要原因,关键的问题在于:想要删除一个AI,你就必须先拿到这个AI的实例……很明显在监听器里我们是拿不到的(而且除非用反射,不然在任何地方你都拿不到,extends也没用),所以这个方法是不行的。(然而仍然可以构造一个新的Selector并赋值上去

纠结归纠结,工作还是不能怠慢的。接下来是Zombie,同样的继承EntityZombie,覆写AI,注释掉this.goalSelector.a(2, new PathfinderGoalMeleeAttack(this, EntityHuman.class, 1.0D, false));this.targetSelector.a(2, new PathfinderGoalNearestAttackableTarget<>(this, EntityHuman.class, true));
Zombie比起其他两个AI复杂一些:客户要求这个实体会跟随玩家。
也就是说你的僵尸会跟着附近的玩家,然后攻击玩家攻击(或者攻击玩家)的实体。其实这个需求完全可以不用靠AI来实现,我们只需要监听EntityDamageByEntityEvent,然后设置攻击源半径xx米内的僵尸攻击目标即可。但我并不想这样做,难得的一次机会,我想试试用NMS实现。
最初我的想法是,自己写一个AI。先给我的Zombie添加一个owner属性,当这个AI被询问是否启动时,检查他是否有主人,如果有,检查他的主人是否被攻击……balabala。当我写一个新的类,继承自PathfinderGoal并开始写我的代码时,我发现我还是太天真了。

再一次被漫天纷飞的abcd整晕了。

再一次陷入僵局,我一边自闭一边审查着自己已经完成的代码。
优先级最高的是在水里浮起。
其次是限制其走位不经过阳光下。
再是迅速离开阳光。
然后是规避狼。

然后……

什么?!骷髅居然怕狼?而且优先级还比攻击高?我还是第一次知道……没想到第一次了解骷髅的这个机制居然是因为翻Minecraft源码……
一只骷髅被狼追击
等等…狼?
我突然知道该怎么做()了。
二话不说,打开EntityWolf,看看他的AI怎么写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public EntityWolf(World world) {
super(world);
this.setSize(0.6F, 0.8F);
((Navigation)this.getNavigation()).a(true);
this.goalSelector.a(1, new PathfinderGoalFloat(this));
this.goalSelector.a(2, this.bm);
this.goalSelector.a(3, new PathfinderGoalLeapAtTarget(this, 0.4F));
this.goalSelector.a(4, new PathfinderGoalMeleeAttack(this, 1.0D, true));
this.goalSelector.a(5, new PathfinderGoalFollowOwner(this, 1.0D, 10.0F, 2.0F));
this.goalSelector.a(6, new PathfinderGoalBreed(this, 1.0D));
this.goalSelector.a(7, new PathfinderGoalRandomStroll(this, 1.0D));
this.goalSelector.a(8, new PathfinderGoalBeg(this, 8.0F));
this.goalSelector.a(9, new PathfinderGoalLookAtPlayer(this, EntityHuman.class, 8.0F));
this.goalSelector.a(9, new PathfinderGoalRandomLookaround(this));
this.targetSelector.a(1, new PathfinderGoalOwnerHurtByTarget(this));
this.targetSelector.a(2, new PathfinderGoalOwnerHurtTarget(this));
this.targetSelector.a(3, new PathfinderGoalHurtByTarget(this, true, new Class[0]));
this.targetSelector.a(4, new PathfinderGoalRandomTargetNonTamed(this, EntityAnimal.class, false, new Predicate() {
public boolean a(Entity entity) {
return entity instanceof EntitySheep || entity instanceof EntityRabbit;
}

public boolean apply(Object object) {
return this.a((Entity)object);
}
}));
this.targetSelector.a(5, new PathfinderGoalNearestAttackableTarget(this, EntitySkeleton.class, false));
this.setTamed(false);
}

好,我想我找到我想要的了。
跟随主人: this.goalSelector.a(5, new PathfinderGoalFollowOwner(this, 1.0D, 10.0F, 2.0F));
攻击 攻击主人 的目标: this.targetSelector.a(1, new PathfinderGoalOwnerHurtByTarget(this));
攻击 主人攻击 的目标: this.targetSelector.a(2, new PathfinderGoalOwnerHurtTarget(this));
找到这些PathfinderGoalOwner的源码,我发现他们的第一个参数需要的都是EntityTameableAnimal,即可被驯服的实体类。很明显我的僵尸继承的EntityZombie并不是可驯服的实体。因此我无法直接使用这个三个PathfinderGoalOwner。
但这并不能阻止我使用原版AI机制的野心,既然我不能直接使用它,那我就把它完全照抄下来。然后把参数类型改成我自己的Zombie就行了。
抄代码的事,谁不会呢。花个几分钟做了三个新的类,然后把对应的代码抄进去。最后在我的Zombie中加入对应的AI,优先级也按照Wolf里的抄。

然后我需要把我的自定义实体添加到游戏中。
Minecraft中有一个类叫EntityTypes,用于记录所有实体的类型,这个类中有五个私有的静态Map。

1
2
3
4
5
6
private static final Map<String, Class<? extends Entity>> c = Maps.newHashMap();
private static final Map<Class<? extends Entity>, String> d = Maps.newHashMap();
private static final Map<Integer, Class<? extends Entity>> e = Maps.newHashMap();
private static final Map<Class<? extends Entity>, Integer> f = Maps.newHashMap();
private static final Map<String, Integer> g = Maps.newHashMap();
public static final Map<Integer, EntityTypes.MonsterEggInfo> eggInfo = Maps.newLinkedHashMap();

一开始我还有些懵逼,为什么记录一个实体类型要用到5个Map?把这件事放到群了和大佬们讨论了一会儿,还被小小的嘲讽了一波。
观察这五个map的类型:
String -> class
class -> String
int -> class
class -> int
String -> int
个人猜测是因为Minecraft的游戏机制过于复杂,有时需要通过entity的class去拿到一个实体的ID,有时又需要用id拿到class,又或者用id拿到实体类型名称等等。

……

我们好像跑题了?

总之,在参考链接的那篇帖子中,楼主说如果覆盖了 int -> class 的则会覆盖掉原版的实体。猜测是原版Minecraft生成生物时,是判断实体ID去生成的。即是说:如果要生成一只僵尸(实体ID为54),则通过这个map找到id为54的class,然后通过反射生成对应的实例并放到世界中。但我这里测试了即使是覆盖了五个所有的map,也无法让我们的实体覆盖掉Minecraft原版生成的。因此我不得不选择使用监听器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@EventHandler
public void onEntitySpawn(EntitySpawnEvent event) {
CraftEntity craftEntity = (CraftEntity) event.getEntity();
Entity entity = craftEntity.getHandle();
Location location = event.getLocation();
World world = ((CraftWorld) location.getWorld()).getHandle();
if (entity.getClass() == EntityZombie.class) {
event.setCancelled(true);
Entity newEntity = new HamsterZombie(event.getLocation());
newEntity.setLocation(location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch());
world.addEntity(newEntity);
} else if (entity.getClass() == EntitySkeleton.class) {
event.setCancelled(true);
Entity newEntity = new HamsterSkeleton(event.getLocation());
newEntity.setLocation(location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch());
world.addEntity(newEntity);
} else if (entity.getClass() == EntitySpider.class) {
event.setCancelled(true);
Entity newEntity = new HamsterSpider(event.getLocation());
newEntity.setLocation(location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch());
world.addEntity(newEntity);
}
}

于是开始测试我的插件是否能够正常运行。

我的僵尸虽然会像狼一样跟随玩家,但却不能攻击玩家攻击(和攻击玩家)的目标。
为了这一个小小的瑕疵,我几乎翻遍了EntityZombie和几个PathfinderGoalOwner以及他们的父类。
EntityInsentient类中翻到了一个比较有用的方法。

1
2
3
public boolean a(Class<? extends EntityLiving> oclass) {
return oclass != EntityGhast.class;
}

这个方法会被目标选择器调用,用于判断该Entity是否会攻击某个类的实体。
于是我给我的僵尸复写了这个方法,当传入的class为玩家时,返回false。这样我的僵尸无论如何都不会攻击玩家了。

一轮排查之后最终我发现,僵尸不会攻击目标的原因在于:我删除了僵尸唯一的一个攻击AI。记得之前我注释掉了的那一行this.goalSelector.a(2, new PathfinderGoalMeleeAttack(this, EntityHuman.class, 1.0D, false));,这是僵尸唯一的攻击AI,若删掉了,则僵尸不会再有任何攻击性。
所以我把僵尸的攻击AI重新加了回去,但是去掉了针对玩家的那个参数:this.goalSelector.a(2, new PathfinderGoalMeleeAttack(this, 1.0D, true));

至此我才真正理解targetSelectorgoalSelector的区别:
targetSelector作为目标选择器,主要告诉实体你要去攻击谁。因此我们的僵尸攻击玩家攻击的目标这个AI,是加给targetSelector的。
goalSelector作为行为选择器(虽然goal也译作目标,但是这里称作行为比较贴切),主要告诉实体你该做什么。因此我们的僵尸跟随玩家这个AI要添加给goalSelector。
goalSelector的AI一般比targetSelector多。拿我的僵尸AI来举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
this.goalSelector.a(0, new PathfinderGoalFloat(this));
this.goalSelector.a(2, new PathfinderGoalMeleeAttack(this, 1.0D, true));
this.goalSelector.a(3, new PathfinderGoalZombieFollowPlayer(this, 1.0D, 10.0F, 2.0F));
this.goalSelector.a(5, new PathfinderGoalMoveTowardsRestriction(this, 1.0D));
this.goalSelector.a(7, new PathfinderGoalRandomStroll(this, 1.0D));
this.goalSelector.a(8, new PathfinderGoalLookAtPlayer(this, EntityHuman.class, 8.0F));
this.goalSelector.a(8, new PathfinderGoalRandomLookaround(this));
if (this.world.spigotConfig.zombieAggressiveTowardsVillager) {
this.goalSelector.a(4, new PathfinderGoalMeleeAttack(this, EntityVillager.class, 1.0D, true));
}

this.goalSelector.a(4, new PathfinderGoalMeleeAttack(this, EntityIronGolem.class, 1.0D, true));
this.goalSelector.a(6, new PathfinderGoalMoveThroughVillage(this, 1.0D, false));

根据优先级的不同,优先级最高的是在水面浮着。当僵尸在水中时,这个AI会被启动,控制僵尸在水中不停地上浮。
其次是攻击AI,当targetSelector选择了一个目标时,这个AI会被启动。此AI会调用僵尸的寻路器,走向并攻击目标实体。
再然后是跟随玩家的AI,这个AI会调用僵尸的寻路器去跟随主人。
之后的自己去理解吧。

可以看到的是,每一个AI都有不同的优先级,当优先级更高的AI被启动时,优先级较低的AI则会被中断。
比如:你的僵尸正在跟随你,然后你攻击了某个目标。targetSelector发现他的主人攻击了某个目标,该AI启动,为僵尸设置了target。然后PathfinderGoalMeleeAttack发现僵尸的target不为null并且存活,该AI将启动并控制僵尸去攻击这个目标。此时僵尸不再跟随你。直到PathfinderGoalMeleeAttack结束(目标死亡或者超出范围)。

参考链接

[Tutorial][Bukkit][Bone Studio]如何自定义你的实体(出处: Minecraft(我的世界)中文论坛)


感谢各位的阅读!

人生不易,仓鼠断气