Minecraft 生物 AI 机制详解(二)
Nickid2018
编辑于 2023年07月23日 00:58
收录于文集
共10篇

在上一篇专栏中我们讨论了目标系统的原理,在这篇文章中我们会讲述另一套 AI 系统,也就是记忆行为系统的原理。

一. 生物记忆

既然是记忆行为系统,那么就肯定有记忆这个东西,生物记忆就是用来记录生物某一项具体信息使用的。例如村民需要记住自己床的位置,否则在睡觉时就找不到了。

生物记忆分为两种,长期记忆和短期记忆。长期记忆可以序列化成 NBT 标签,也就是通过 data 命令能获取的那些记忆,这些记忆在区块卸载的时候也能被保存,区块再次加载就能还原回来。但是短期记忆不同,这些记忆不能序列化为 NBT 标签,区块卸载后就会立刻丢掉。长期记忆可以在 wiki 找到(https://minecraft.fandom.com/zh/wiki/%E7%94%9F%E7%89%A9%E8%AE%B0%E5%BF%86)。

很多记忆都有“有效期”,过了一段时间之后记忆会自动忘记。

代码中获得和抹除记忆由 Brain 类的 setMemory、setMemoryWithExpiry 和 eraseMemory 负责。

二. 行为

与目标系统不同,在记忆行为系统中生物的动作由行为定义。行为的基类是 BehaviorControl,它的定义如下:

代码块
clike
自动换行
复制代码
public interface BehaviorControl<E extends LivingEntity> {
    Behavior.Status getStatus();
    boolean tryStart(ServerLevel level, E entity, long time);
    void tickOrStop(ServerLevel level, E entity, long time);
    void doStop(ServerLevel level, E entity, long time);
    String debugString();
}
复制成功

getStatus 是获取行为状态的方法,行为只有两种状态:STOPPED(停止)和 RUNNING(正在运行)。tryStart 是尝试执行行为,如果行为成功开始执行返回 true。tickOrStop 是每刻执行一次行为并决定是否停止。doStop 是立刻停止。

这个接口一共有 4 个直接子类,接下来将对这四个子类进行详细说明。

1. Behavior

所有使用记忆行为系统的生物的持续行为都是这个类的子类,它将 BehaviorControl 中定义的行为生命周期进行了进一步的细化。

代码块
clike
自动换行
复制代码
@Override
public final boolean tryStart(ServerLevel level, E entity, long time) {
	if (hasRequiredMemories(entity) && checkExtraStartConditions(level, entity)) {
		status = Status.RUNNING;
		int duration = minDuration + level.getRandom().nextInt(maxDuration + 1 - minDuration);
		endTimestamp = time + duration;
		start(level, entity, time);
		return true;
	}
	return false;
}
复制成功

持续行为的启动需要检查两个条件:记忆是否满足条件、子类定义的额外条件 checkExtraStartConditions 是否满足。如果这两个条件都满足,持续行为才能启动。持续行为拥有一个结束时间戳,当时间超出这个时间戳后行为会自动停止,而这个持续时间由子类定义。如果子类没有定义,则使用 60 游戏刻,即持续行为默认只会持续 3 秒。

持续行为初始化时会指定需要或不需要什么记忆。记忆具有三种状态:VALUE_PRESENT(记忆存在)、VALUE_ABSENT(记忆不存在)和 REGISTERED(记忆已注册)。只有生物的记忆满足持续行为定义的每一条记忆限制,这个持续行为才能启动。例如下方持续行为的初始化代码:

代码块
clike
自动换行
复制代码
public WorkAtPoi() {
	super(ImmutableMap.of(
		MemoryModuleType.JOB_SITE, MemoryStatus.VALUE_PRESENT,
		MemoryModuleType.LOOK_TARGET, MemoryStatus.REGISTERED
	));
}
复制成功

这个持续行为就需要 JOB_SITE 这个记忆存在,且 LOOK_TARGET 记忆已经注册。如果生物不具有 JOB_SITE 记忆,则持续行为不能执行。而 LOOK_TARGET 的存在与否并不重要,它在生物生成时就已经注册了,所以条件肯定满足。

代码块
clike
自动换行
复制代码
@Override
public final void tickOrStop(ServerLevel level, E entity, long time) {
	if (!timedOut(time) && canStillUse(level, entity, time))
		tick(level, entity, time);
	else
		doStop(level, entity, time);
}
复制成功

持续行为可以继续的条件是:时间没有超过结束时间戳且由子类定义的 canStillUse 返回 true。如果条件不满足则直接 doStop。

2. DoNothing

听名字就知道这个类代表什么都不做,不修改记忆也不进行任何操作,但这不意味着生物“停止思考”。与持续行为一样,它具有结束时间戳,具体时长由初始化时的参数决定。

3. GateBehavior

这个行为可以“挑选”出某些行为执行。

代码块
clike
自动换行
复制代码
public GateBehavior(Map<MemoryModuleType<?>, MemoryStatus> entryCondition,
					Set<MemoryModuleType<?>> exitErasedMemories,
					OrderPolicy orderPolicy,
					RunningPolicy runningPolicy,
					List<Pair<? extends BehaviorControl<? super E>, Integer>> behaviors) {
	this.entryCondition = entryCondition;
	this.exitErasedMemories = exitErasedMemories;
	this.orderPolicy = orderPolicy;
	this.runningPolicy = runningPolicy;
	behaviors.forEach(entry -> this.behaviors.add(entry.getFirst(), entry.getSecond()));
}
复制成功

初始化的第一个参数仍然是记忆条件,第二个参数代表这个行为结束后要抹除的记忆,第三个和第四个参数代表这个行为的运作模式,最后一个参数代表这个行为可以选择的行为列表。

行为列表 behaviors 是一个带有权重的列表。列表内的元素在被打乱时可以按照权重随机生成数字,按照生成的数字进行重新排序得到打乱后的列表,而元素权重越大元素排在前面的概率也就越大。

代码块
clike
自动换行
复制代码
@Override
public final boolean tryStart(ServerLevel level, E entity, long time) {
	if (hasRequiredMemories(entity)) {
		status = Behavior.Status.RUNNING;
		orderPolicy.apply(behaviors);
		runningPolicy.apply(behaviors.stream(), level, entity, time);
		return true;
	}
	return false;
}
复制成功

orderPolicy 决定行为列表是否要打乱。如果为 ORDERED,则不进行打乱,使用初始化时的列表顺序;如果为 SHUFFLED,则进行按权重的打乱。

runningPolicy 决定要启动执行多少行为。如果为 RUN_ONE,则只会执行行为列表中第一个能执行的行为;如果为 TRY_ALL,则会尝试启动行为列表中的所有行为。

GateBehavior 有一个子类,RunOne。它默认使用 SHUFFLED 和 RUN_ONE 两个常量,即打乱并只执行一个。

下面使用猪灵空闲游走时的 AI 进行举例。

代码块
clike
自动换行
复制代码
private static RunOne<Piglin> createIdleMovementBehaviors() {
	return new RunOne<>(ImmutableList.of(
		Pair.of(RandomStroll.stroll(0.6f), 2),
		Pair.of(InteractWith.of(EntityType.PIGLIN, 8, MemoryModuleType.INTERACTION_TARGET, 0.6f, 2), 2),
		Pair.of(BehaviorBuilder.triggerIf(PiglinAi::doesntSeeAnyPlayerHoldingLovedItem, SetWalkTargetFromLookTarget.create(0.6f, 3)), 2),
		Pair.of(new DoNothing(30, 60), 1)
	));
}
复制成功

从上面的代码可以看出,猪灵在空闲游走时只会选择一个行为,其中随机游走、跟随其他猪灵、跟随手上有喜爱物品的 AI 权重相同,而停止不动权重较小。

GateBehavior 的结束条件是所有被执行的行为都结束,RunOne 的结束条件也类似。

4. OneShot

这个行为在启动后会立刻停止,是用于实现“触发器”的。

代码块
clike
自动换行
复制代码
@Override
public final boolean tryStart(ServerLevel level, E entity, long time) {
	if (trigger(level, entity, time)) {
		status = Behavior.Status.RUNNING;
		return true;
	}
	return false;
}

@Override
public final void tickOrStop(ServerLevel level, E entity, long time) {
	doStop(level, entity, time);
}
复制成功

这个类是抽象类,它的唯一实现在 BehaviorBuilder 里面。

代码块
clike
自动换行
复制代码
public static <E extends LivingEntity> OneShot<E> create(Function<Instance<E>, ? extends App<Mu<E>, Trigger<E>>> func) {
	TriggerWithResult<E, Trigger<E>> triggerResult = BehaviorBuilder.get(func.apply(BehaviorBuilder.instance()));
	return new OneShot<E>(){
		@Override
		public boolean trigger(ServerLevel level, E entity, long time) {
			Trigger trigger = triggerResult.tryTrigger(level, entity, time);
			if (trigger == null)
				return false;
			return trigger.trigger(level, entity, time);
		}
		// 无关部分已省略
	};
}
复制成功

这个方法传入的参数是将 BehaviorBuilder.Instance 实例转变为 App 实例的函数,这个函数中需要写明触发器的触发条件和执行代码。由于方法较多,且大多具有相似性,我们以例子作为说明,下方的代码取自 SetWalkTargetFromLookTarget。

代码块
clike
自动换行
复制代码
public static OneShot<LivingEntity> create(Predicate<LivingEntity> entityPredicate,
											Function<LivingEntity, Float> speedModifier,
											int closeEnoughDist) {
	return BehaviorBuilder.create(instance -> instance.group(
		instance.absent(MemoryModuleType.WALK_TARGET),
		instance.present(MemoryModuleType.LOOK_TARGET)
	).apply(instance, (memoryAccessor1, memoryAccessor2) -> (level, entity, time) -> {
		if (!entityPredicate.test(entity))
			return false;
		memoryAccessor1.set(new WalkTarget(instance.get(memoryAccessor2), speedModifier.apply(entity), closeEnoughDist));
		return true;
	}));
}
复制成功

首先来说第一层 lambda,参数是 BehaviorBuilder.Instance。它的 group 方法内定义了这个触发器的条件,使用方法 absent、present 和 registered 描述指定记忆的状态。group 内可以有多个记忆条件,但是不能超过 datafixerupper 库定义的最大数量。apply 或 point 方法内是触发器的执行代码,这两个的不同主要在 apply 是必须定义 group 的,而 point 不需要。

第二层 lambda 的参数是 memoryAccessor1 和 memoryAccessor2。它们的类型都是 MemoryAccessor,这是一个用于获取和修改记忆的转换类。这两个对象分别对应了 group 内两个记忆,即第一个参数代表了生物的 WALK_TARGET 记忆,第二个代表 LOOK_TARGET。group 内定义的条件与这里的参数息息相关,记忆条件在这里全部转换为 MemoryAccessor,并且一一对应。

最里层的 lambda 就是具体执行的代码,参数是 ServerLevel 对象、操作的生物和时间,要求返回一个布尔值代表触发是否成功,决定 OneShot 行为是否真正执行成功。

所以上方的代码可以这么解释:如果生物没有行走目标但具有看向的目标,且满足 entityPredicate 的条件时,生物会把看向的目标作为行走的目标。

OneShot 还有各种简单创建方式。

代码块
clike
自动换行
复制代码
public static <E extends LivingEntity> OneShot<E> sequence(Trigger<? super E> var0, Trigger<? super E> var1) // 执行第一个触发器成功后再执行第二个触发器
public static <E extends LivingEntity> OneShot<E> triggerIf(Predicate<E> var0, OneShot<? super E> var1) // 第一个相当于触发条件,第二个是触发器代码
/* 下面两个相当于触发器的简单实现 */
public static <E extends LivingEntity> OneShot<E> triggerIf(Predicate<E> var0)
public static <E extends LivingEntity> OneShot<E> triggerIf(BiPredicate<ServerLevel, E> var0)
复制成功

三. 感知器

和目标系统一样,记忆行为系统也有探测其他实体的类。目标系统中是 TargetGoal 负责这一工作,而记忆行为系统中是 Sensor 和它的子类完成这些工作,并且它的功能从选择攻击对象扩展到了选择生物各种行为的对象。

感知器有两个重要的方法:requires 代表了这个感知器需要什么记忆,这些记忆将在之后的行为调度器内部定义;tick 是感知器每刻执行的代码。

代码块
clike
自动换行
复制代码
public final void tick(ServerLevel level, E entity) {
	if (--timeToTick <= 0L) {
		timeToTick = scanRate;
		doTick(level, entity);
	}
}
复制成功

感知器不是每刻都能成功执行,而是每 scanRate 刻才能真正执行 doTick 一次。scanRate 通常为 20,即 1 秒钟探测一次。

四. 行为调度

现在来到了这篇专栏的重头戏:这些行为是怎么被安排在一起的呢?

首先,我们需要介绍生物的“活动”,代码里面对应了 Activity 这个类。生物的活动不止一种,比如核心活动、空闲活动等等。在同一时刻也不一定只有一种活动在进行,比如核心活动就可以和空闲活动一起进行,但是非核心活动只能有一个

每个生物都有两种重要的活动:一是核心活动,这些活动会一直保持活跃,代码里面对应的是 coreActivities 和设置它的 setCoreActivities;另一个是默认活动,如果在之后的计算过程中生物想要从一种非核心活动转移到另一种非核心活动时条件不满足,则会把这个活动设置为活跃活动,在默认情况下是 IDLE。

添加活动时需要使用 addActivityAndRemoveMemoriesWhenStopped 和它的简单版本方法。

代码块
clike
自动换行
复制代码
public void addActivityAndRemoveMemoriesWhenStopped(
	Activity activity,
	ImmutableList<? extends Pair<Integer, ? extends BehaviorControl<? super E>>> behaviors,
	Set<Pair<MemoryModuleType<?>, MemoryStatus>> requirements,
	Set<MemoryModuleType<?>> memoriesErase
) {
	activityRequirements.put(activity, requirements);
	if (!memoriesErase.isEmpty())
		activityMemoriesToEraseWhenStopped.put(activity, memoriesErase);
	for (Pair<Integer, ? extends BehaviorControl<? super E>> pair : behaviors)
		availableBehaviorsByPriority.computeIfAbsent(pair.getFirst(), k -> Maps.newHashMap())
			.computeIfAbsent(activity, k -> Sets.newLinkedHashSet()).add(pair.getSecond());
}
复制成功

其中第一个参数就是添加的活动;第二项是活动内的行为列表,内部是优先级 - 行为的键值对;第三项是行为的记忆条件;第四项是活动结束后需要清除的记忆。

以悦灵的 AI 举例,下方是悦灵 AI 的初始化。

代码块
clike
自动换行
复制代码
protected static Brain<?> makeBrain(Brain<Allay> brain) {
	initCoreActivity(brain);
	initIdleActivity(brain);
	brain.setCoreActivities(ImmutableSet.of(Activity.CORE));
	brain.setDefaultActivity(Activity.IDLE);
	brain.useDefaultActivity();
	return brain;
}

private static void initCoreActivity(Brain<Allay> brain) {
	brain.addActivity(Activity.CORE, 0, ImmutableList.of(
		new Swim(0.8f), new AnimalPanic(2.5f),
		new LookAtTargetSink(45, 90), new MoveToTargetSink(),
		new CountDownCooldownTicks(MemoryModuleType.LIKED_NOTEBLOCK_COOLDOWN_TICKS),
		new CountDownCooldownTicks(MemoryModuleType.ITEM_PICKUP_COOLDOWN_TICKS)
	));
}

private static void initIdleActivity(Brain<Allay> brain) {
	brain.addActivityWithConditions(Activity.IDLE, ImmutableList.of(
		Pair.of(0, GoToWantedItem.create(var0 -> true, 1.75f, true, 32)),
		Pair.of(1, new GoAndGiveItemsToTarget(AllayAi::getItemDepositPosition, 2.25f, 20)),
		Pair.of(2, StayCloseToTarget.create(AllayAi::getItemDepositPosition, 4, 16, 2.25f)),
		Pair.of(3, SetEntityLookTargetSometimes.create(6.0f, UniformInt.of(30, 60))),
		Pair.of(4, new RunOne(ImmutableList.of(
			Pair.of(RandomStroll.fly(1.0f), 2),
			Pair.of(SetWalkTargetFromLookTarget.create(1.0f, 3), 2),
			Pair.of(new DoNothing(30, 60), 1)
		)))
	), ImmutableSet.of());
}
复制成功

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

  • 悦灵只有一个核心活动 CORE,没有记忆条件,内部所有行为的优先级都为 0。

  • 悦灵只有一个非核心活动 IDLE,同时它也是默认活动,没有记忆条件,内部有 5 个不同优先级的行为。

生物的非核心活动有多种设置方式。第一种是直接设置,调用 setActiveActivityIfPossible(如果目标活动条件满足则设置为目标条件,如果不满足设置为默认活动)或 setActiveActivityToFirstValid(按顺序尝试设置活动,如果条件都不满足则不改变当前活跃的非核心活动);第二种是通过日程表设置,如果当前时间的活动条件满足则转换,否则转换为默认活动。

简单介绍完活动后,我们可以正式开始行为调度的讲解了。下面是 Brain 类的 tick 方法。

代码块
clike
自动换行
复制代码
public void tick(ServerLevel level, E entity) {
	forgetOutdatedMemories();
	tickSensors(level, entity);
	startEachNonRunningBehavior(level, entity);
	tickEachRunningBehavior(level, entity);
}
复制成功

第一行是用于忘记过时记忆的,第二行是调用感知器,第三行第四行是行为调度的代码。可以看到在生物 AI 计算的时候计算顺序是刷新记忆->调用感知器->启动未运行行为->执行行为。第一行代码将在下一部分讲解,第三行第四行的方法如下:

代码块
clike
自动换行
复制代码
private void startEachNonRunningBehavior(ServerLevel level, E entity) {
	long time = level.getGameTime();
	for (Map<Activity, Set<BehaviorControl<E>>> behaviors : availableBehaviorsByPriority.values())
		for (Map.Entry<Activity, Set<BehaviorControl<E>>> entry : behaviors.entrySet()) {
			Activity activity = entry.getKey();
			if (!activeActivities.contains(activity))
				continue;
			Set<BehaviorControl<E>> availableBehaviors = entry.getValue();
			for (BehaviorControl<E> behavior : availableBehaviors) {
				if (behavior.getStatus() != Behavior.Status.STOPPED)
					continue;
				behavior.tryStart(level, entity, time);
			}
		}
}

private void tickEachRunningBehavior(ServerLevel level, E entity) {
	long time = level.getGameTime();
	for (BehaviorControl<E> behavior : getRunningBehaviors())
		behavior.tickOrStop(level, entity, time);
}
复制成功

从上面可以看出行为调度其实非常的简单:按照优先级排序所有的行为,并按照优先级从高到低逐一尝试启动对应活动正在活跃的不在运行的行为。而行为的停止不受此处行为调度的影响,只有在行为本身决定停止时才会停止(只有村民有一个例外可以主动停止全部行为)。

下面仍然使用悦灵的 AI 举例。

悦灵的核心活动只有 CORE,分别为 Swim(游泳)、AnimalPanic(惊慌状态的逃窜)、LookAtTargetSink(看向 LOOK_TARGET)、MoveToTargetSink(向 WALK_TARGET 移动)和两个 CountDownCooldownTicks(LIKED_NOTEBLOCK_COOLDOWN_TICKS 和 ITEM_PICKUP_COOLDOWN_TICKS 的倒计时)。

悦灵的非核心活动只有 IDLE,按照优先级先后各个行为如下:

  • 触发器行为 GoToWantedItem,记忆条件为 WALK_TARGET、LOOK_TARGET 和 ITEM_PICKUP_COOLDOWN_TICKS 已注册,且 NEAREST_VISIBLE_WANTED_ITEM 存在。效果是:如果 ITEM_PICKUP_COOLDOWN_TICKS 不存在、手上有物品、物品在世界边界内且物品距离悦灵 32 格内,悦灵会飞向想要物品的位置。

  • 持续行为 GoAndGiveItemsToTarget,持续时间 20 刻,记忆条件为 WALK_TARGET、LOOK_TARGET 和 ITEM_PICKUP_COOLDOWN_TICKS 已注册。效果是:如果悦灵物品栏内有对应物品,会尝试飞向要将这个物品投掷出去的位置;如果距离投掷位置小于 3 格,则将物品栏内物品投出去。

  • 触发器行为 StayCloseToTarget,记忆条件为 LOOK_TARGET 已注册且 WALK_TARGET 不存在。效果是如果悦灵在物品投掷位置 16 格外会尝试飞向物品的投掷位置。

  • 触发器行为 SetEntityLookTargetSometimes,记忆条件为 LOOK_TARGET 不存在且 NEAREST_VISIBLE_LIVING_ENTITIES 存在。效果是偶尔看向 6 格内的生物。

  • 挑选行为 RunOne,分别为:权重为 2 的触发器行为 RandomStroll,记忆条件为 WALK_TARGET 不存在,效果是随机游走。权重为 2 的触发器行为 SetWalkTargetFromLookTarget,记忆条件是 LOOK_TARGET 存在但 WALK_TARGET 不存在,效果是飞向看向的地方。权重为 1 的 DoNothing,没有条件。

感觉很复杂?举几个例子就好理解了!

假设悦灵手上没有物品,那么第一个行为的条件不满足,第二个第三个不能启动,第四个可能满足,第五个前两个可能满足。这样的效果是,悦灵偶尔看向最近的生物,会随机飞行或飞向看向生物的位置,有时候可能停止在原地。

如果玩家给了悦灵物品,此时第二个行为不能启动,其他行为都有可能满足。

假如悦灵发现了一个对应物品且完成了冷却,第一个行为条件满足可以启动,WALK_TARGET 被设置,这时剩下的行为条件不满足、不能启动或是 DoNothing,表面上看就是悦灵飞向了对应物品的位置。

如果悦灵已经拿到了对应物品,此时第二个行为可以启动。由于第二个行为优先级低于第一个行为,也就是执行更晚,因此它可以覆盖掉第一个行为对 WALK_TARGET 的修改,但是又因为对 WALK_TARGET 的修改只发生在这个行为启动的时候,而第一个行为是触发器行为,每刻都可以启动,然后立刻停止,所以在这个行为进行中时第一个行为也可以把 WALK_TARGET 改到其他位置上去。所以我们可以看到悦灵是这样处理的:如果悦灵投掷物品的位置距离悦灵比较远,那么它就会先收集物品直到物品栏满,再飞向指定位置投出物品;如果距离较近,那么悦灵可能边收集物品边投掷物品。

五. 举例:村民与铁傀儡生成

下面我们讲讲这整套系统的使用,以村民在惊慌状态下生成铁傀儡举例。

先说说村民的惊慌状态,它对应的是 PANIC 活动,而这个活动由 VillagerPanicTrigger 触发。

代码块
clike
自动换行
复制代码
@Override
protected void start(ServerLevel level, Villager entity, long time) {
	if (VillagerPanicTrigger.isHurt(entity) || VillagerPanicTrigger.hasHostile(entity)) {
		Brain<Villager> brain = var1.getBrain();
		if (!brain.isActive(Activity.PANIC)) {
			brain.eraseMemory(MemoryModuleType.PATH);
			brain.eraseMemory(MemoryModuleType.WALK_TARGET);
			brain.eraseMemory(MemoryModuleType.LOOK_TARGET);
			brain.eraseMemory(MemoryModuleType.BREED_TARGET);
			brain.eraseMemory(MemoryModuleType.INTERACTION_TARGET);
		}
		brain.setActiveActivityIfPossible(Activity.PANIC);
	}
}
复制成功

如果村民周围有恐吓生物(由感知器 VillagerHostilesSensor 写入到 NEAREST_HOSTILE)或受到了攻击(由感知器 HurtBySensor 写入 HURT_BY),则村民进入惊慌活动。当前面两种条件都不满足,这个持续行为就会停止。

那么铁傀儡呢?铁傀儡生成写在了 VillagerPanicTrigger 持续行为的 tick 里面。

代码块
clike
自动换行
复制代码
@Override
protected void tick(ServerLevel level, Villager entity, long time) {
	if (time % 100L == 0L) {
		entity.spawnGolemIfNeeded(level, time, 3);
}
复制成功

可以看到村民是每当游戏时间可以被 100 整除时尝试生成铁傀儡,即每 5 秒尝试生成一次。当然这不是每次都能成功的,spawnGolemIfNeeded 的具体代码如下。

代码块
clike
自动换行
复制代码
// Villager::spawnGolemIfNeeded
public void spawnGolemIfNeeded(ServerLevel levelNow, long time, int count) {
	if (!wantsToSpawnGolem(time))
		return;
	AABB aabb = getBoundingBox().inflate(10.0, 10.0, 10.0);
	List<Villager> villagers = levelNow.getEntitiesOfClass(Villager.class, aabb);
	List wants = villagers.stream()
		.filter(v -> v.wantsToSpawnGolem(time))
		.limit(5L)
		.collect(Collectors.toList());
	if (wants.size() < count)
		return;
	if (!SpawnUtil.trySpawnMob(EntityType.IRON_GOLEM, MobSpawnType.MOB_SUMMONED, levelNow, blockPosition(), 10, 8, 6, SpawnUtil.Strategy.LEGACY_IRON_GOLEM).isPresent())
		return;
	villagers.forEach(GolemSensor::golemDetected);
}

// Villager::wantsToSpawnGolem
public boolean wantsToSpawnGolem(long time) {
	if (!golemSpawnConditionsMet(level.getGameTime()))
		return false;
	return !brain.hasMemoryValue(MemoryModuleType.GOLEM_DETECTED_RECENTLY);
}

// Villager::golemSpawnConditionsMet
private boolean golemSpawnConditionsMet(long time) {
	Optional<Long> sleptTime = brain.getMemory(MemoryModuleType.LAST_SLEPT);
	if (var1.isPresent())
		return time - sleptTime.get() < 24000L;
	return false;
}

// GolemSensor::golemDetected
public static void golemDetected(LivingEntity entity) {
	entity.getBrain().setMemoryWithExpiry(MemoryModuleType.GOLEM_DETECTED_RECENTLY, true, 600L);
}
复制成功

从上面的代码中,可以看出铁傀儡生成的条件:

  • 这个村民和以它的碰撞箱为中心扩展 10 格的范围内至少有其他两个村民也满足下面的条件,即最少需要三个满足下面条件的村民。

  • 村民必须在 24000 游戏刻,即 20 分钟内睡过一次觉。

  • 村民不存在记忆 GOLEM_DETECTED_RECENTLY。

如果上面的条件满足了,那么上述范围内的所有村民(不管有没有满足后两条条件)都会获得过期时间为 600 游戏刻的 GOLEM_DETECTED_RECENTLY 记忆,并且一个铁傀儡会在以判定村民脚部为中心的 17×13×17 的范围内生成。

GOLEM_DETECTED_RECENTLY 记忆的获取途径不止这一个,在 GolemSensor 中也会尝试寻找以村民的碰撞箱扩展 16 格范围内的铁傀儡,如果探测到了也会获得过期时间为 600 游戏刻的 GOLEM_DETECTED_RECENTLY 记忆。

由于铁傀儡必须在 GOLEM_DETECTED_RECENTLY 不存在时生成,而这两种方式给的记忆过期时间都为 600 游戏刻,所以铁傀儡生成的最短时钟就是 600 刻,30 秒了。。。吗?

在 22w12a 前,确实如此,这个记忆会在第 600 刻被清除,所以村民在这时是可以成功生成铁傀儡的;但是在 22w12a 及以后,这个记忆在第 600 刻仍然存在,在第 601 刻才被消除,而因为生成铁傀儡是每 5 秒尝试一次,所以最短时钟是 35 秒而不是 30 秒。那么这是为什么呢?

这其实是因为 Mojang 在设计“过期时间”上的问题。在 22w12a 之前,记忆是先减时间后清除;在 22w12a 及之后是先清除后减时间。这导致过期时间这个定义发生了变化,在 22w12a 之前,它其实指的是这个记忆在第几刻被清除,而此刻过期时间为 0 是无效值,如果此刻过期时间为 1 就代表在下一刻被清除;但是经过 22w12a 这次改变,导致它的定义变成了从此刻开始到记忆清除中间要经过多少刻,也就是此刻过期时间为 0 不是无效值,并且它代表下一刻被清除,或者说莫名其妙的加了额外的 1 刻延迟。

forgetOutdatedMemories 方法在 22w11a -> 22w12a 之间的对比

这个改动会让基于原先时钟的刷铁机效率减半,因为两次时钟才有一次成功;如果修改并成功匹配时钟,效率也会相比原先降低 14.3% 左右,解决办法只有加单元。

六. 村民行为

接下来我们讲一个生物整体 AI 的例子,就是村民的行为。由于村民有不同的类型,在这里我们只挑成年村民做例子。

村民使用日程表决定当前的活动。所有成年村民都使用 VILLAGER_DEFAULT 日程表,它的定义如下:

代码块
clike
自动换行
复制代码
public static final Schedule VILLAGER_DEFAULT = Schedule.register("villager_default")
	.changeActivityAt(10, Activity.IDLE)
	.changeActivityAt(2000, Activity.WORK)
	.changeActivityAt(9000, Activity.MEET)
	.changeActivityAt(11000, Activity.IDLE)
	.changeActivityAt(12000, Activity.REST)
	.build();
复制成功

可以看出,村民的日程如下:

  • 00010 - 02000(6:36 - 8:00):空闲活动。

  • 02000 - 09000(8:00 - 15:00):工作活动(如果为无业或傻子则为空闲活动)。

  • 09000 - 11000(15:00 - 17:00):聚集活动(如果村庄聚集点不存在则为空闲活动)。

  • 11000 - 12000(17:00 - 18:00):空闲活动。

  • 12000 - 00010(18:00 - 次日 6:36):休息活动。

先从空闲活动说起,空闲活动的定义如下。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getIdlePackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(2, new RunOne(ImmutableList.of(
			Pair.of(InteractWith.of(EntityType.VILLAGER, 8, MemoryModuleType.INTERACTION_TARGET, speedModifier, 2), 2),
			Pair.of(InteractWith.of(EntityType.VILLAGER, 8, AgeableMob::canBreed, AgeableMob::canBreed, MemoryModuleType.BREED_TARGET, speedModifier, 2), 1),
			Pair.of(InteractWith.of(EntityType.CAT, 8, MemoryModuleType.INTERACTION_TARGET, speedModifier, 2), 1),
			Pair.of(VillageBoundRandomStroll.create(speedModifier), 1),
			Pair.of(SetWalkTargetFromLookTarget.create(speedModifier, 2), 1),
			Pair.of(new JumpOnBed(speedModifier), 1),
			Pair.of(new DoNothing(30, 60), 1)
		))),
		Pair.of(3, new GiveGiftToHero(100)),
		Pair.of(3, SetLookAndInteract.create(EntityType.PLAYER, 4)),
		Pair.of(3, new ShowTradesToPlayer(400, 1600)),
		Pair.of(3, new GateBehavior(
			ImmutableMap.of(), ImmutableSet.of(MemoryModuleType.INTERACTION_TARGET),
			GateBehavior.OrderPolicy.ORDERED, GateBehavior.RunningPolicy.RUN_ONE,
			ImmutableList.of(Pair.of(new TradeWithVillager(), 1))
		)),
		Pair.of(3, new GateBehavior(
			ImmutableMap.of(), ImmutableSet.of(MemoryModuleType.BREED_TARGET),
			GateBehavior.OrderPolicy.ORDERED, GateBehavior.RunningPolicy.RUN_ONE,
			ImmutableList.of(Pair.of(new VillagerMakeLove(), 1))
		)),
		getFullLookBehavior(),
		Pair.of(99, UpdateActivityFromSchedule.create())
	);
}
复制成功

可以看出,村民在空闲活动下有下面这些行为:

  • 优先级最高的行为有:走向其他村民(权重最大)、走向可繁殖的村民、走向猫、随机游走、走向看向的地方和在床上跳跃(成年村民此行为不能启动)。上面这些行为只能启动一个,互相排斥。

  • 优先级较低的有:送给有村庄英雄的玩家礼物、看向玩家、给玩家展示交易物品、村民间丢物品和村民繁殖。这些行为可以并行,但是有些行为触发后会使其他行为记忆不满足。

  • 优先级更低的是看向其他实体。

  • 优先级最低的是更新日程表。

村民的工作活动如下:

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getWorkPackage(VillagerProfession profession, float speedModifier) {
	WorkAtPoi workAtPoi = profession == VillagerProfession.FARMER ? new WorkAtComposter() : new WorkAtPoi();
	return ImmutableList.of(
		getMinimalLookBehavior(),
		Pair.of(5, new RunOne(ImmutableList.of(
			Pair.of(workAtPoi, 7),
			Pair.of(StrollAroundPoi.create(MemoryModuleType.JOB_SITE, 0.4f, 4), 2),
			Pair.of(StrollToPoi.create(MemoryModuleType.JOB_SITE, 0.4f, 1, 10), 5),
			Pair.of(StrollToPoiList.create(MemoryModuleType.SECONDARY_JOB_SITE, speedModifier, 1, 6, MemoryModuleType.JOB_SITE), 5),
			Pair.of(new HarvestFarmland(), profession == VillagerProfession.FARMER ? 2 : 5),
			Pair.of(new UseBonemeal(), profession == VillagerProfession.FARMER ? 4 : 7)
		))),
		Pair.of(10, new ShowTradesToPlayer(400, 1600)),
		Pair.of(10, SetLookAndInteract.create(EntityType.PLAYER, 4)),
		Pair.of(2, SetWalkTargetFromBlockMemory.create(MemoryModuleType.JOB_SITE, speedModifier, 9, 100, 1200)),
		Pair.of(3, new GiveGiftToHero(100)),
		Pair.of(99, UpdateActivityFromSchedule.create())
	);
}
复制成功

可以看出,村民在工作活动中是这样的:

  • 优先级最高的是走向工作站点,如果是失业村民这项无法启动。

  • 优先级低一点的是送给村庄英雄礼物。

  • 优先级其次的是看向其他实体(偷懒是吧)和一个挑选行为。这些挑选行为失业村民都无法启动,包括在工作站点工作(权重为 7)、游走向工作站点(权重为 10)、游走在工作站点周围(权重为 4)、游走于可选工作站点(权重为 5)、收获并种下作物(农民权重为 2,其他村民为 5 但不可启动)、使用骨粉催熟(农民权重为 4,其他村民为 7)。

  • 优先级更低的是展示交易和看向玩家。

  • 优先级最低的是更新活动。

可以看出,不是农民的村民也可以使用骨粉(很意外吧),甚至权重更高。村民在工作站点周围不是一直呆着,而是游走于周围,并有时回到工作站点工作。

村民的聚集活动如下:

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getMeetPackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(2, TriggerGate.triggerOneShuffled(ImmutableList.of(
			Pair.of(StrollAroundPoi.create(MemoryModuleType.MEETING_POINT, 0.4f, 40), 2),
			Pair.of(SocializeAtBell.create(), 2)
		))),
		Pair.of(10, new ShowTradesToPlayer(400, 1600)),
		Pair.of(10, SetLookAndInteract.create(EntityType.PLAYER, 4)),
		Pair.of(2, SetWalkTargetFromBlockMemory.create(MemoryModuleType.MEETING_POINT, speedModifier, 6, 100, 200)),
		Pair.of(3, new GiveGiftToHero(100)),
		Pair.of(3, ValidateNearbyPoi.create(poi -> poi.is(PoiTypes.MEETING), MemoryModuleType.MEETING_POINT)),
		Pair.of(3, new GateBehavior(
			ImmutableMap.of(), ImmutableSet.of(MemoryModuleType.INTERACTION_TARGET),
			GateBehavior.OrderPolicy.ORDERED, GateBehavior.RunningPolicy.RUN_ONE,
			ImmutableList.of(Pair.of(new TradeWithVillager(), 1))
		)),
		getFullLookBehavior(),
		Pair.of(99, UpdateActivityFromSchedule.create())
	);
}
复制成功

可以看出村民在聚集活动时有下面的行为:

  • 优先级最高为走向村庄聚集点、在聚集点附近走动或与其他村民社交。

  • 其次为送给村庄英雄礼物、验证周围聚集点有效性和丢给其他村民物品。

  • 更低的是展示交易物品和看向玩家。

  • 再低一点的是看向其他实体。

  • 最低的是更新活动。

聚集活动要求必须有村庄聚集点,也就是钟。如果不存在聚集点,这段时间内将变为空闲活动。

村民的休息活动如下:

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getRestPackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(2, SetWalkTargetFromBlockMemory.create(MemoryModuleType.HOME, speedModifier, 1, 150, 1200)),
		Pair.of(3, ValidateNearbyPoi.create(var0 -> var0.is(PoiTypes.HOME), MemoryModuleType.HOME)),
		Pair.of(3, new SleepInBed()),
		Pair.of(5, new RunOne(
			ImmutableMap.of(MemoryModuleType.HOME, MemoryStatus.VALUE_ABSENT),
			ImmutableList.of(
				Pair.of(SetClosestHomeAsWalkTarget.create(speedModifier), 1),
				Pair.of(InsideBrownianWalk.create(speedModifier), 4),
				Pair.of(GoToClosestVillage.create(speedModifier, 4), 2),
				Pair.of(new DoNothing(20, 40), 2)
			)
		)),
		getMinimalLookBehavior(),
		Pair.of(99, UpdateActivityFromSchedule.create()));
}
复制成功

村民的休息活动含有下面这些行为:

  • 优先级最高的是走回自己的床铺,如果床不存在这项不能启动。

  • 优先级其次是验证床的有效性和在床上睡觉。

  • 优先级再低一点是一个选择行为,这个行为的启动条件是床不存在。包括走向最近的床(无论是否被占用,权重为 1)、在不露天的位置徘徊(权重为 4)、走回最近的村庄(权重为 2)和什么也不做(权重为 2)。与它一个优先级的是看向其他实体。

  • 优先级最低的是更新活动。

那么村民获得工作的行为在哪里呢?其实它写在了村民的核心活动里面。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getCorePackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(0, new Swim(0.8f)),
		Pair.of(0, InteractWithDoor.create()),
		Pair.of(0, new LookAtTargetSink(45, 90)),
		Pair.of(0, new VillagerPanicTrigger()),
		Pair.of(0, WakeUp.create()),
		Pair.of(0, ReactToBell.create()),
		Pair.of(0, SetRaidStatus.create()),
		Pair.of(0, ValidateNearbyPoi.create(profession.heldJobSite(), MemoryModuleType.JOB_SITE)),
		Pair.of(0, ValidateNearbyPoi.create(profession.acquirableJobSite(), MemoryModuleType.POTENTIAL_JOB_SITE)),
		Pair.of(1, new MoveToTargetSink()),
		Pair.of(2, PoiCompetitorScan.create()),
		Pair.of(3, new LookAndFollowTradingPlayerSink(speedModifier)),
		Pair.of(5, GoToWantedItem.create(speedModifier, false, 4)),
		Pair.of(6, AcquirePoi.create(profession.acquirableJobSite(), MemoryModuleType.JOB_SITE, MemoryModuleType.POTENTIAL_JOB_SITE, true, Optional.empty())),
		Pair.of(7, new GoToPotentialJobSite(speedModifier)),
		Pair.of(8, YieldJobSite.create(speedModifier)),
		Pair.of(10, AcquirePoi.create(poi -> poi.is(PoiTypes.HOME), MemoryModuleType.HOME, false, Optional.of((byte) 14))),
		Pair.of(10, AcquirePoi.create(poi -> poi.is(PoiTypes.MEETING), MemoryModuleType.MEETING_POINT, true, Optional.of((byte) 14))),
		Pair.of(10, AssignProfessionFromJobSite.create()),
		Pair.of(10, ResetProfession.create())
	);
}
复制成功

核心活动中定义了村民最重要的行为,比如游泳、与门互动、看向和走向某个位置、变为惊慌状态、从床上苏醒、对钟声起反应和变为袭击活动。所有有关于村民就业的行为也在这里定义,比如验证工作站点和验证可能的工作站点、检查竞争工作站点、获取工作站点、转让工作站点和重置职业。村庄聚集点和床位的获取也是在这里定义的。具体怎么处理工作站点在下一章节会涉及。

除了核心活动和四种日程活动外,村民还有四种活动,但都需要在某种情况下才会触发。

惊慌活动只有在村民受恐吓或者受伤时才会出现,它的产生代码在上文生成铁傀儡中已经说明。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getPanicPackage(VillagerProfession profession, float speedModifier) {
	float panicSpeed = speedModifier * 1.5f;
	return ImmutableList.of(
		Pair.of(0, VillagerCalmDown.create()),
		Pair.of(1, SetWalkTargetAwayFrom.entity(MemoryModuleType.NEAREST_HOSTILE, panicSpeed, 6, false)), 
		Pair.of(1, SetWalkTargetAwayFrom.entity(MemoryModuleType.HURT_BY_ENTITY, panicSpeed, 6, false)),
		Pair.of(3, VillageBoundRandomStroll.create(panicSpeed, 2, 2)),
		getMinimalLookBehavior()
	);
}
复制成功

在惊慌活动中,村民的速度会提升 50%,逃离恐吓实体和伤害村民的实体。当村民远离伤害实体 6 格且不存在恐吓实体时惊慌活动自动解除。

如果玩家触发了袭击,那么村民会进入袭击活动或准备袭击活动。

准备袭击活动发生在袭击将要开始和袭击波次之间的时间内。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getPreRaidPackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(0, RingBell.create()),
		Pair.of(0, TriggerGate.triggerOneShuffled(ImmutableList.of(
			Pair.of(SetWalkTargetFromBlockMemory.create(MemoryModuleType.MEETING_POINT, speedModifier * 1.5f, 2, 150, 200), 6),
			Pair.of(VillageBoundRandomStroll.create(speedModifier * 1.5f), 2)
		))),
		getMinimalLookBehavior(), 
		Pair.of(99, ResetRaidStatus.create())
	);
}
复制成功

可以看出,村民在袭击间会鸣钟,跑向钟或快速的无目的的走动。

当袭击正在进行或袭击结束的一段时间内,村民进入袭击活动。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getRaidPackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(0, BehaviorBuilder.sequence(
			BehaviorBuilder.triggerIf(VillagerGoalPackages::raidExistsAndNotVictory), 
			TriggerGate.triggerOneShuffled(ImmutableList.of(
				Pair.of(MoveToSkySeeingSpot.create(speedModifier), 5),
				Pair.of(VillageBoundRandomStroll.create(speedModifier * 1.1f), 2)
			))
		)),
		Pair.of(0, new CelebrateVillagersSurvivedRaid(600, 600)),
		Pair.of(2, BehaviorBuilder.sequence(
			BehaviorBuilder.triggerIf(VillagerGoalPackages::raidExistsAndActive),
			LocateHidingPlace.create(24, speedModifier * 1.4f, 1)
		)),
		getMinimalLookBehavior(),
		Pair.of(99, ResetRaidStatus.create())
	);
}
复制成功

在这段代码中,Mojang 写错了一个方法的名字,raidExistsAndNotVictory 其实是 raidExistsAndVictory。上面的代码说明:如果袭击胜利村民会走出屋外找到露天的位置开始庆祝,如果袭击在进行中则去找可以躲藏的位置。

村民的最后一个活动是躲藏活动,当鸣钟后村民会进入这个活动。

代码块
clike
自动换行
复制代码
public static ImmutableList<Pair<Integer, ? extends BehaviorControl<? super Villager>>> getHidePackage(VillagerProfession profession, float speedModifier) {
	return ImmutableList.of(
		Pair.of(0, SetHiddenState.create(15, 3)),
		Pair.of(1, LocateHidingPlace.create(32, speedModifier * 1.25f, 2)),
		getMinimalLookBehavior()
	);
}
复制成功

可以看出村民在躲藏活动中会尽快找到躲藏的位置,通常这个活动只会持续 300 刻。

七. 常见行为

下面将简单介绍一些行为,这些行为被大多数使用这个系统的实体使用。

1. POI 类行为

POI,全称 Point of Interest,也就是“兴趣点”。它的作用是保存世界上一些特殊方块的位置,以便于快速查找,并记录这个方块被多少个实体“占领”。

AcquirePoi

生物检查周围 POI 的行为是 AcquirePoi,它的具体代码比较复杂。

代码块
clike
自动换行
复制代码
public static BehaviorControl<PathfinderMob> create(Predicate<Holder<PoiType>> predicate, MemoryModuleType<GlobalPos> mainMemory, MemoryModuleType<GlobalPos> potentialMemory, boolean checkBaby, Optional<Byte> eventID) {
	MutableLong nextScanTime = new MutableLong(0);
	Long2ObjectMap<JitteredLinearRetry> entries = new Long2ObjectOpenHashMap<>();
	OneShot<PathfinderMob> oneShot = BehaviorBuilder.create(instance -> instance.group(
		instance.absent(potentialMemory)
	).apply(instance, potential -> {
		return (level, entity, time) -> {
			if (checkBaby && entity.isBaby())
				return false;
			else if (nextScanTime.getValue() == 0L) {
				nextScanTime.setValue(level.getGameTime() + level.random.nextInt(20));
				return false;
			} else if (level.getGameTime() < nextScanTime.getValue())
				return false;
			else {
				nextScanTime.setValue(time + 20 + level.getRandom().nextInt(20));
				PoiManager poiManager = level.getPoiManager();
				entries.long2ObjectEntrySet().removeIf(entry -> !entry.getValue().isStillValid(time));
				Set<Pair<Holder<PoiType>, BlockPos>> set = poiManager.findAllClosestFirstWithType(predicate, pos -> {
					JitteredLinearRetry entry = entries.get(pos.asLong());
					if (entry == null)
						return true;
					else if (!entry.shouldRetry(time))
						return false;
					else {
						entry.markAttempt(time);
						return true;
					}
				}, entity.blockPosition(), 48, Occupancy.HAS_SPACE).limit(5).collect(Collectors.toSet());
				Path path = findPathToPois(entity, set);
				if (path != null && path.canReach()) {
					BlockPos target = path.getTarget();
					poiManager.getType(target).ifPresent(holder -> {
						poiManager.take(predicate, (holder2, blockPos) -> blockPos.equals(target), blockPos, 1);
						potential.set(GlobalPos.of(level.dimension(), target));
						eventID.ifPresent(event -> level.broadcastEntityEvent(entity, event));
						entries.clear();
						DebugPackets.sendPoiTicketCountPacket(serverLevel, target);
					});
				} else {
					while(Pair<Holder<PoiType>, BlockPos> pair : var14)
						entries.computeIfAbsent(pair.getSecond().asLong(), m -> new JitteredLinearRetry(level.random, time));
				}
				return true;
			}
		};
	}));
	return potentialMemory == mainMemory ?
		oneShot :
		BehaviorBuilder.create(instance -> instance.group(
			instance.absent(mainMemory)
		).apply(instance, memoryAccessor -> oneShot));
}
复制成功

AcquirePoi 要求了两个记忆,一个是存储认领 POI 位置的记忆,一个是存储可能成为认领 POI 的位置的记忆。这个行为本身不会将 POI 位置信息写入第一个记忆,而是写入第二个记忆。如果两个记忆是相同的,则这个行为只会在这个记忆不存在时可能执行,并且等效于这个行为直接指定了生物认领这个 POI;如果两个记忆不同,那么这个行为必须在这两个记忆都不存在时可能执行,并且在第二个记忆中的 POI 信息需要另一个行为写入到第一个记忆。

以村民为例,村民的核心活动中使用了 3 次这个行为,分别用于:

  • HOME,也就是床位。

  • MEETING,村庄聚集点。

  • JOB_SITE、POTENTIAL_JOB_SITE,村民工作站点。

床位和聚集点的行为两个记忆都相同,也就是发现之后就能立刻成功认领。而工作站点的两个记忆不同,将潜在工作站点变为正式工作站点需要 GoToPotentialJobSite (走向潜在的工作站点,要求非核心活动必须是空闲、工作或玩耍)和 AssignProfessionFromJobSite 操作,而在这转变的过程中村民可能会因为竞争失去这个 POI 的认领。

AcquirePoi 不是每刻都能成功执行,它的扫描间隔在 21~40 刻左右,扫描范围是以生物为中心半径 48 格内的离得最近的 5 个与生物要求匹配且不在冷却时间内的 POI。每次扫描会检查生物能否找到一条可行路径到达某个 POI 的认领半径(通常为 1,钟为 6)内。如果找到了一条路径,则这个 POI 被生物认领,如果没有找到任何路径,则所有这些 POI 都会被标记一个 40 到 80 刻的冷却时间,代表在这段时间内这些位置不会再次检测。如果冷却时间过后这些位置仍然不可到达,那么会再次标记冷却,并且增加 40 到 80 刻的冷却时间,直到冷却时间到达 400 刻冷却重置。

ValidateNearbyPoi

认领 POI 后,生物需要检查这个 POI 是否仍然存在,这就是 ValidateNearbyPoi 的工作。

代码块
clike
自动换行
复制代码
public static BehaviorControl<LivingEntity> create(Predicate<Holder<PoiType>> predicate, MemoryModuleType<GlobalPos> poiPosition) {
	return BehaviorBuilder.create(instance -> instance.group(
		instance.present(poiPosition)
	).apply(instance, memoryAccessor -> (level, entity, time) -> {
		GlobalPos globalPos = instance.get(memoryAccessor);
		BlockPos pos = globalPos.pos();
		if (level.dimension() != globalPos.dimension() || !pos.closerToCenterThan(var4.position(), 16.0))
			return false;
		ServerLevel serverLevel = level.getServer().getLevel(globalPos.dimension());
		if (serverLevel == null || !serverLevel.getPoiManager().exists(pos, predicate))
			memoryAccessor.erase();
		else if (ValidateNearbyPoi.bedIsOccupied(serverLevel, pos, entity)) {
			memoryAccessor.erase();
			level.getPoiManager().release(pos);
			DebugPackets.sendPoiTicketCountPacket(level, pos);
		}
		return true;
	}));
}
复制成功

当生物与 POI 位于同一个维度,且距离小于 16 格时才会检查 POI 是否存在。也就是说,如果一个 POI 被破坏时生物距离它大于 16 格,生物就不会知道这个 POI 已经不存在了,直到生物回到原 POI 位置 16 格内或找不到可达路径超过一定时间(具体代码在 SetWalkTargetFromBlockMemory)才会发现 POI 已经不存在并抹除记忆。

村民曾经认领了红色羊毛的床,在传送到现在的位置后打掉床,这样村民就不会再认领身边的床。因为村民可以找到到达原 POI 的位置的路径并且距离超过了 16 格,所以 HOME 记忆一直存在。

对于床,这个行为有特殊的判定。如果床被其他生物或玩家先行占用,则生物也会认为 POI 无效并抹除记忆,同时解除对这个 POI 的占用。

YieldJobSite

POI 行为有很多,在这里最后再举一个例子,这个行为和村民让出工作站点有关。

代码块
clike
自动换行
复制代码
public static BehaviorControl<Villager> create(float speedModifier) {
	return BehaviorBuilder.create(instance -> instance.group(
		instance.present(MemoryModuleType.POTENTIAL_JOB_SITE),
		instance.absent(MemoryModuleType.JOB_SITE),
		instance.present(MemoryModuleType.NEAREST_LIVING_ENTITIES),
		instance.registered(MemoryModuleType.WALK_TARGET),
		instance.registered(MemoryModuleType.LOOK_TARGET)
	).apply(instance, (memory1, memory2, memory3, memory4, memory5) -> (level, entity, time) -> {
		if (entity.isBaby())
			return false;
		if (entity.getVillagerData().getProfession() != VillagerProfession.NONE)
			return false;
		BlockPos poiPos = instance.get(memory1).pos();
		Optional<Holder<PoiType>> type = level.getPoiManager().getType(poiPos);
		if (poiPos.isEmpty())
			return true;
		instance.get(memory3).stream()
			.filter(e -> e instanceof Villager && e != entity)
			.map(e -> (Villager) e)
			.filter(LivingEntity::isAlive)
			.filter(e -> YieldJobSite.nearbyWantsJobsite(type.get(), e, poiPos))
			.findFirst().ifPresent(yieldTarget -> {
				memory4.erase();
				memory5.erase();
				memory1.erase();
				if (yieldTarget.getBrain().getMemory(MemoryModuleType.JOB_SITE).isEmpty()) {
					BehaviorUtils.setWalkAndLookTargetMemories(yieldTarget, poiPos, speedModifier, 1);
					var6.getBrain().setMemory(MemoryModuleType.POTENTIAL_JOB_SITE, GlobalPos.of(var62.dimension(), poiPos));
					DebugPackets.sendPoiTicketCountPacket(level, poiPos);
				}
			});
		return true;
	}));
}
复制成功

当一个无业村民找到潜在工作站点后,这个行为会扫描周围的村民。如果周围有无工作站点的有职业村民,且这个村民的职业与潜在工作站点匹配,并且这个村民可以到达这个工作站点,那么这个无业村民就会让出这个工作站点并终止行动。

这个行为曾经在 22w45a 重构了一次,但是由于 Mojang 的疏忽,将条件给反转了,写成了这样:

代码块
clike
自动换行
复制代码
if (entity.getVillagerData().getProfession() == VillagerProfession.NONE)
	return false;
复制成功

这使得这个行为的运行条件变成了“无工作站点有职业的村民”。如果有两个同样职业且没有工作站点的村民在一起,在放下它们对应工作方块后,它们就会互相转让这个工作方块。由于这个行为的优先级比较低,代表了它在村民 AI 较后执行,而互相让出工作方块会清除行走目标和观察目标,导致这两个村民看起来像是被“冻结”了(而且这个效果非常明显,会突然停下来)。这个漏洞对应 MC-258295,直到 23w03a 才被修复,也就是说在 1.19.3 会受到这个漏洞的影响。

2. LookAtTargetSink 和 MoveToTargetSink

在上一篇专栏中我们说过生物控制器通常需要调用寻路系统或直接调用,可是在上面的代码中我们看到的都是使用 WALK_TARGET 行走目标记忆和 LOOK_TARGET 观察目标记忆。使用这两种记忆并将这些数据传递给控制器和寻路系统的行为就是这两个。

WALK_TARGET 是一个短期记忆,类型是 WalkTarget,它含有三个字段:target(目标位置)、speedModifier(行走速度)和 closeEnoughDist(到达距离)。

代码块
clike
自动换行
复制代码
private boolean reachedTarget(Mob mob, WalkTarget target) {
	return target.getTarget().currentBlockPosition().distManhattan(mob.blockPosition()) <= target.getCloseEnoughDist();
}
复制成功

生物到达行走目标的判定是距离行走目标的曼哈顿距离小于到达距离。行走目标可以绑定在一个方块上也可以是一个实体,所以在行为执行时需要重新计算路径。

代码块
clike
自动换行
复制代码
private boolean tryComputePath(Mob mob, WalkTarget target, long time) {
	BlockPos pos = target.getTarget().currentBlockPosition();
	path = mob.getNavigation().createPath(var3, 0);
	speedModifier = target.getSpeedModifier();
	Brain<?> brain = mob.getBrain();
	if (reachedTarget(mob, target))
		brain.eraseMemory(MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE);
	else {
		boolean canReach = path != null && path.canReach();
		if (canReach)
			brain.eraseMemory(MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE);
		else if (!brain.hasMemoryValue(MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE))
			brain.setMemory(MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE, time);
		if (path != null)
			return true;
		Vec3 randomPos = DefaultRandomPos.getPosTowards(mob, 10, 7, Vec3.atBottomCenterOf(pos), 1.5707963705062866);
		if (randomPos != null) {
			path = mob.getNavigation().createPath(randomPos.x, randomPos.y, randomPos.z, 0);
			return path != null;
		}
	}
	return false;
}
复制成功

如果行走目标与上一次计算路径时的位置距离 2 格以上,生物就会开始重新计算行走路径。如果重新计算路径后发现无法直接到达目标,就会获得 CANT_REACH_WALK_TARGET_SINCE 记忆。这个记忆只有在生物成功到达目标或重新计算找到了一条合适的路径时才会清除,它影响其他的行为判断是否放弃掉这个目标。如果生物完全无法找到一条路径,就会生成一个随机游走终点并尝试走向那个位置,在下一刻可能会继续重新计算路径。如果行为停止时生物因为卡住而没有到达行走目标,那么这个生物会获得不大于 40 刻的行走冷却时间,在这段时间内不会再尝试行走。

生物观察要比生物行走简单得多,仅通过当前位置让生物追随目标就可以。

3. MeleeAttack

与目标系统一样,记忆行为系统也有近战攻击这个行为,但是它们的实现就不太一样了。

代码块
clike
自动换行
复制代码
public static OneShot<Mob> create(int cooldown) {
	return BehaviorBuilder.create(instance -> instance.group(
		instance.registered(MemoryModuleType.LOOK_TARGET),
		instance.present(MemoryModuleType.ATTACK_TARGET),
		instance.absent(MemoryModuleType.ATTACK_COOLING_DOWN),
		instance.present(MemoryModuleType.NEAREST_VISIBLE_LIVING_ENTITIES)
	).apply(instance, (memory1, memory2, memory3, memory4) -> (level, entity, time) -> {
		LivingEntity attackTarget = instance.get(memory2);
		if (!isHoldingUsableProjectileWeapon(entity) &&
			entity.isWithinMeleeAttackRange(attackTarget) &&
			instance.get(memory4).contains(attackTarget)) {
			memory1.set(new EntityTracker(attackTarget, true));
			entity.swing(InteractionHand.MAIN_HAND);
			entity.doHurtTarget(attackTarget);
			memory3.setWithExpiry(true, cooldown);
			return true;
		}
		return false;
	}));
}
复制成功

可以看出当生物攻击目标存在并且能探测到攻击目标时才能攻击,如果生物本身可以使用远程攻击武器而身上刚好有这种物品那么就不会进行近战攻击。比如猪灵,在它拿着弩的时候它是不会近战攻击的,只会射箭;只有当它不拿弩的时候它才会近战攻击。


这就是这篇专栏的全部内容了。有问题可以在评论区指出。

混淆映射表:Mojang Mapping。

版本:1.19.3~23w05a。

反混淆器:Nickid2018/GitMCDecomp

反编译器:CFR 0.152