实现自定义 AITask

警告

这些文档是为 SpongeAPI 7 编写的,可能已经过时。 如果你觉得你可以帮助更新它们,请提交一个 PR!

在开始实现自定义 AITask 之前,首先要确保我们的工作环境已正确配置。请在按照“在插件中使用 MCP”一文中的流程操作。

下面列出了实现 AITask 时所有需要用到的类。

import java.util.Comparator;
import java.util.Optional;
import java.util.function.Predicate;

import org.spongepowered.api.GameRegistry;
import org.spongepowered.api.entity.Entity;
import org.spongepowered.api.entity.ai.task.AITask;
import org.spongepowered.api.entity.ai.task.AITaskType;
import org.spongepowered.api.entity.ai.task.AbstractAITask;
import org.spongepowered.api.entity.living.Agent;

import com.flowpowered.math.vector.Vector3d;
import com.google.common.base.Preconditions;

import net.minecraft.entity.EntityLiving;
import net.minecraft.entity.ai.EntityAIBase;
import net.minecraft.pathfinding.PathNavigate;

为简化实现,我们使用下列常量。当然这些常量的使用是可选的,你也可以把它们换成字段,并在构造器里赋值。

private static final double MOVEMENT_SPEED = 1;
private static final double APPROACH_DISTANCE_SQUARED = 2 * 2;
private static final double MAX_DISTANCE_SQUARED = 20 * 20;
private static final float EXECUTION_CHANCE = 0.2F;
private static final int MUTEX_FLAG_MOVE = 1; // Minecraft bit flag

如果你需要实现你自己的 AITask,你需要继承 AbstractAITask。下面是一个这样的完全自定义的 AITask 的例子。同时,我们需要为这个新的 AITask 生成一个新的 AITaskType。为了避免业务逻辑分散得到处都是,我们在这里只用一个静态字段以及一个 register 方法,如下所示:

public class CreepyCompanionAITask extends AbstractAITask<Agent> {

    private static AITaskType TYPE;

    public static void register(final Object plugin, final GameRegistry gameRegistry) {
        TYPE = gameRegistry
                .registerAITaskType(plugin, "creepy_companion", "CreepyCompanion", CreepyCompanionAITask.class);
    }

    [...]

}

自然地,我们得在我们的主类中调用这个方法,但这也不是很困难:

@Listener
public void onInitialize(final GameInitializationEvent event) {
    CreepyCompanionAITask.register(this, game.getRegistry());
}

在此之后我们终于可以开始实现 AITask 了。为此,我们需要一个构造器,以及七个方法:

  • CreepyCompanionAITask(...)

  • boolean canRunConcurrentWith(AITask<Agent>)

  • boolean canBeInterrupted()

  • boolean shouldUpdate()

  • void start()

  • boolean continueUpdating()

  • void update()

  • void reset()

下图描述了这些方法的大致执行顺序:

The method execution order of the ai task

我们需要通过构造器来传入所有我们希望 AITask 拥有的参数,同时调整其一些基本配置。在这里我们设定了 AITaskType 和 Mutex Bit,并传入了名为 entityFilterPredicate 供以后使用。

private final Predicate<Entity> entityFilter;

private Optional<Entity> optTarget;

public CreepyCompanionAITask(final Predicate<Entity> entityFilter) {
    super(TYPE);
    this.entityFilter = Preconditions.checkNotNull(entityFilter);
    ((EntityAIBase) (Object) this).setMutexBits(MUTEX_FLAG_MOVE);
}

很不幸,我们需要肮脏地转型到 Minecraft 的底层类上去,这也是为什么我们需要重新配置工作环境以使用 MCP 的原因。

在此之后,我们首先实现容易实现的 canRunConcurrentWithcanBeInterrupted。对于前者,我们直接依赖原版逻辑,但这再次需要肮脏的转型。对于后者,我们只需要根据情况判断它是否可以中断即可。

@Override
public boolean canRunConcurrentWith(final AITask<Agent> other) {
    return (((EntityAIBase) (Object) this).getMutexBits() & ((EntityAIBase) other).getMutexBits()) == 0;
}

@Override
public boolean canBeInterrupted() {
    return true;
}

在此之后,我们需要开始正式实现这个 AITask 的逻辑了——“什么时候开始执行逻辑”,和“具体的逻辑是什么”。我们首先实现“什么时候开始执行逻辑”,通常这需要两步:

  • 根据随机数确定执行概率,以此决定是否执行

  • 寻找合适的对象

具体的逻辑其实就是在我们找到的目标实体上执行我们想要它做的事情。

@Override
public boolean shouldUpdate() {
    final Agent owner = getOwner().get();
    if (owner.getRandom().nextFloat() > EXECUTION_CHANCE) {
        return false;
    }

    final Vector3d position = getPositionOf(owner);
    this.optTarget = owner.getWorld()
            .getEntities().stream()
            .filter(this.entityFilter)
            .filter(e -> getPositionOf(e).distanceSquared(position) < MAX_DISTANCE_SQUARED)
            .min(Comparator.comparingDouble(e -> getPositionOf(e).distanceSquared(position)));
    return this.optTarget.isPresent();
}

@Override
public void start() {
    getNavigator().tryMoveToEntityLiving((net.minecraft.entity.Entity) this.optTarget.get(), MOVEMENT_SPEED);
}

通常我们可以直接使用 owner 的 Random 对象来获得随机数,但实际上任何随机数都可以。Entity 有一个 Random 对象的原因只是为了避免重复创建多余的 Random 对象并散落得到处都是。

搜寻目标的逻辑非常简单明了:首先我们拿到当前世界中所有加载实体的列表,并使用 entityFilter 这个 Predicate 进行过滤,然后排除所有离得太远(这里是 20 格)的实体。你可以根据情况修改刚才提到的最大距离,但请注意这个距离越大,寻路的计算量也越大。

在找到合适目标之后,我们只需要让实体向目标实体移动即可。在这里,移动速度是个非常关键的因素:速度太快会让它不小心错过目标,最后导致它在目标之间来回跑(所以请确保你计算出的可接受条件在合理范围之内),速度太慢的话又会令它看上去胜似原地踏步(不仅看上去很奇怪,而且对于“跟着 X”这样的 AITask 来说这未免有些滑稽)。

上述代码中包含了一些提高可读性的辅助方法,下面给出了这些方法的实现:

private Vector3d getPositionOf(final Entity entity) {
    return entity.getLocation().getPosition();
}

private PathNavigate getNavigator() {
    return ((EntityLiving) (getOwner().get())).getNavigator();
}

第一个方法只是单纯把多个调用打成一个。第二个方法则用于拿到实体的 PathNavigate(或者说移动控制器)对象,而这再次需要转型到 Minecraft 底层类。

最后,我们需要实现“该任务是否应当继续执行”的检查,以及“完成任务之前必须的清理工作“。

@Override
public boolean continueUpdating() {
    if (getNavigator().noPath()) {
        return false;
    }
    if (!this.optTarget.isPresent()) {
        return false;
    }
    final Entity target = this.optTarget.get();
    return getPositionOf(target).distanceSquared(getPositionOf(getOwner().get())) > APPROACH_DISTANCE_SQUARED;
}

@Override
public void update() {
}

@Override
public void reset() {
    getNavigator().clearPath();
    this.optTarget = Optional.empty();
}

对于我们的这个例子来说这两个都很简单。首先,我们检查实体是否遇到了死路或者已经足够接近目标实体了。Minecraft 会在我们返回 false 时调用 reset()。我们应当在 reset 中进行清理操作。对于我们这个例子来说,我们需要清理掉目标实体的引用,避免内存泄漏。若是我们在刚才那个方法中返回了 true,Minecraft 则会继续调用 update(),但在我们这个例子中它没有任何操作,因为实体移动和 AI 是分开的两套系统,实体此时仍然会向目标移动。

以上是实现 AITask 所必须的所有步骤了,在此之后我们就可以按照“实体 AI”一文的“添加 AI 任务”小节中描述的那样,在某一实体中使用这个新的 AITask 了。

但我们为什么管这个叫 CreepyCompanionAITask?很简单:我们并不在检查 AITask 是否应当执行时检查实体是否已经距离目标实体足够近了,因此有这个 AI 的实体会随着时间流逝一步一步向目标实体接近。然后我们大概只需要让这个实体只在目标实体没有看着它时接近目标就行了——想象一下 Creeper 从背后接近你的感觉。