Minecraft 生物 AI 機制詳解(二)

在上一篇專欄中我們討論了目標系統的原理,在這篇文章中我們會講述另一套 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,它的定義如下:

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 中定義的行為生命周期進行了進一步的細化。

@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(記憶已注冊)。只有生物的記憶滿足持續行為定義的每一條記憶限制,這個持續行為才能啟動。例如下方持續行為的初始化代碼:

public WorkAtPoi() { super(ImmutableMap.of( MemoryModuleType.JOB_SITE, MemoryStatus.VALUE_PRESENT, MemoryModuleType.LOOK_TARGET, MemoryStatus.REGISTERED )); }

這個持續行為就需要 JOB_SITE 這個記憶存在,且 LOOK_TARGET 記憶已經注冊。如果生物不具有 JOB_SITE 記憶,則持續行為不能執行。而 LOOK_TARGET 的存在與否並不重要,它在生物生成時就已經注冊了,所以條件肯定滿足。

@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

這個行為可以「挑選」出某些行為執行。

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 是一個帶有權重的列表。列表內的元素在被打亂時可以按照權重隨機生成數字,按照生成的數字進行重新排序得到打亂後的列表,而元素權重越大元素排在前面的概率也就越大。

@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 進行舉例。

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

這個行為在啟動後會立刻停止,是用於實現「觸發器」的。

@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 裡面。

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。

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 還有各種簡單創建方式。

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 是感知器每刻執行的代碼。

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 和它的簡單版本方法。

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 的初始化。

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 方法。

public void tick(ServerLevel level, E entity) { forgetOutdatedMemories(); tickSensors(level, entity); startEachNonRunningBehavior(level, entity); tickEachRunningBehavior(level, entity); }

第一行是用於忘記過時記憶的,第二行是調用感知器,第三行第四行是行為調度的代碼。可以看到在生物 AI 計算的時候計算順序是刷新記憶->調用感知器->啟動未運行行為->執行行為。第一行代碼將在下一部分講解,第三行第四行的方法如下:

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 觸發。

@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 裡面。

@Override protected void tick(ServerLevel level, Villager entity, long time) { if (time % 100L == 0L) { entity.spawnGolemIfNeeded(level, time, 3); }

可以看到村民是每當遊戲時間可以被 100 整除時嘗試生成鐵傀儡,即每 5 秒嘗試生成一次。當然這不是每次都能成功的,spawnGolemIfNeeded 的具體代碼如下。

// 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 日程表,它的定義如下:

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):休息活動。

先從空閑活動說起,空閑活動的定義如下。

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()) ); }

可以看出,村民在空閑活動下有下面這些行為:

優先順序最高的行為有:走向其他村民(權重最大)、走向可繁殖的村民、走向貓、隨機遊走、走向看向的地方和在床上跳躍(成年村民此行為不能啟動)。上面這些行為只能啟動一個,互相排斥。

優先順序較低的有:送給有村莊英雄的玩家禮物、看向玩家、給玩家展示交易物品、村民間丟物品和村民繁殖。這些行為可以並行,但是有些行為觸發後會使其他行為記憶不滿足。

優先順序更低的是看向其他實體。

優先順序最低的是更新日程表。

村民的工作活動如下:

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)。

優先順序更低的是展示交易和看向玩家。

優先順序最低的是更新活動。

可以看出,不是農民的村民也可以使用骨粉(很意外吧),甚至權重更高。村民在工作站點周圍不是一直呆著,而是遊走於周圍,並有時回到工作站點工作。

村民的聚集活動如下:

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()) ); }

可以看出村民在聚集活動時有下面的行為:

優先順序最高為走向村莊聚集點、在聚集點附近走動或與其他村民社交。

其次為送給村莊英雄禮物、驗證周圍聚集點有效性和丟給其他村民物品。

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

再低一點的是看向其他實體。

最低的是更新活動。

聚集活動要求必須有村莊聚集點,也就是鍾。如果不存在聚集點,這段時間內將變為空閑活動。

村民的休息活動如下:

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)。與它一個優先順序的是看向其他實體。

優先順序最低的是更新活動。

那麼村民獲得工作的行為在哪裡呢?其實它寫在了村民的核心活動裡面。

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()) ); }

核心活動中定義了村民最重要的行為,比如游泳、與門互動、看向和走向某個位置、變為驚慌狀態、從床上蘇醒、對鐘聲起反應和變為襲擊活動。所有有關於村民就業的行為也在這裡定義,比如驗證工作站點和驗證可能的工作站點、檢查競爭工作站點、獲取工作站點、轉讓工作站點和重置職業。村莊聚集點和床位的獲取也是在這裡定義的。具體怎麼處理工作站點在下一章節會涉及。

除了核心活動和四種日程活動外,村民還有四種活動,但都需要在某種情況下才會觸發。

驚慌活動只有在村民受恐嚇或者受傷時才會出現,它的產生代碼在上文生成鐵傀儡中已經說明。

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 格且不存在恐嚇實體時驚慌活動自動解除。

如果玩家觸發了襲擊,那麼村民會進入襲擊活動或准備襲擊活動。

准備襲擊活動發生在襲擊將要開始和襲擊波次之間的時間內。

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()) ); }

可以看出,村民在襲擊間會鳴鍾,跑向鍾或快速的無目的的走動。

當襲擊正在進行或襲擊結束的一段時間內,村民進入襲擊活動。

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。上面的代碼說明:如果襲擊勝利村民會走出屋外找到露天的位置開始慶祝,如果襲擊在進行中則去找可以躲藏的位置。

村民的最後一個活動是躲藏活動,當鳴鍾後村民會進入這個活動。

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,它的具體代碼比較複雜。

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 (mutableLong.getValue() == 0L) { nextScanTime.setValue(level.getGameTime() + level.random.nextInt(20)); return false; } else if (level.getGameTime() < mutableLong.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 的工作。

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 行為有很多,在這裡最後再舉一個例子,這個行為和村民讓出工作站點有關。

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 的疏忽,將條件給反轉了,寫成了這樣:

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(到達距離)。

private boolean reachedTarget(Mob mob, WalkTarget target) { return target.getTarget().currentBlockPosition().distManhattan(mob.blockPosition()) <= target.getCloseEnoughDist(); }

生物到達行走目標的判定是距離行走目標的曼哈頓距離小於到達距離。行走目標可以綁定在一個方塊上也可以是一個實體,所以在行為執行時需要重新計算路徑。

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

與目標系統一樣,記憶行為系統也有近戰攻擊這個行為,但是它們的實現就不太一樣了。

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

隨便看看 更多