using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Necromancer pet summon/ownership/recall - BACKLOG.md item 3 (Zombie Dog) plus the Insect /// Swarm added alongside its bugfix (2026-08-28). Done the same way vanilla's own drone works /// (per user request): one active pet per player PER SPECIES, and the pet is recorded as /// owned via the same EntityAlive.ownedEntities API the drone itself uses /// (ItemActionSpawnTurret.ExecuteAction calls holdingEntity.AddOwnedEntity(entityDrone) - /// confirmed by decompiling it). /// /// Each summon book's item block now carries the SpawnEntity action on BOTH Action0 and /// Action1 (user's own suggestion 2026-08-28, in response to "how do I put the dog back in /// the book?"), pointed at the same pet entity in both slots, but the two slots mean /// something different: Action0 (primary click) SUMMONS, blocked with a tooltip if one is /// already out; Action1 (secondary/"power attack" click) RECALLS the owned one if there is /// one, or no-ops with a tooltip if there isn't. This split is entirely our own Prefix logic /// below, keyed off ItemActionData.indexInEntityOfAction (0/1, public field, confirmed by /// decompiling ItemActionData) - ItemActionSpawnEntity itself has no concept of "recall", /// it's purely a spawner. /// /// LimitedPets keys off entity_class name -> the three localization keys each species needs /// (already-active, recalled, nothing-to-recall). Each species gets its own independent /// 1-active limit (a Dog and a Swarm can be out at once; two Dogs can't). /// /// Two patch points for the SUMMON path, because entity creation and item consumption/ /// ownership can't both live in one method without either a transpiler (to grab a local /// variable) or a fragile "guess the newest entity of this class" lookup: /// /// 1. Prefix on ItemActionSpawnEntity.Spawn - runs BEFORE anything is created. Handles both /// the summon-side block/allow AND the entire recall path (recall never lets the /// original method run at all - there's nothing for vanilla Spawn to do on a recall). /// 2. Postfix on EntityFactory.CreateEntity(int,Vector3,Vector3) - the exact overload /// ItemActionSpawnEntity.Spawn() calls, and it returns the created Entity directly /// (unlike Spawn() itself, which is void), so this is the only point that has both "an /// entity was just created" and "here it is" without needing IL tricks. Filtered to our /// pets' entityClassIds so it's a no-op for every other CreateEntity call in the game /// (turrets, drones, zombie spawns, everything else uses this exact same overload). /// Because the Prefix above already guarantees at most one pet of that species per /// player, this postfix doesn't need to re-check the limit - it only ever fires for /// allowed summons. /// /// This overload of CreateEntity has no "who spawned this" parameter, so the postfix finds /// the owner as the nearest player to the spawn position - always correct here since /// ItemActionSpawnEntity.Spawn() always spawns at (roughly) the caster's own head position. /// Fine for this mod (no multiplayer/persistence layer exists anywhere else in it either); /// not a general-purpose "find the real spawner" solution. /// /// Known gap vs. the real drone: no drones.dat-style save file, no despawn-on-owner-death. /// A pet is a plain EntityAlive with no special unload/reload handling, so if its chunk /// unloads while the player is away it despawns like any other wandering entity - the real /// drone avoids that via DroneManager's own persistence system, which is a lot of machinery /// (network sync, its own save file) this mod doesn't have a reason to take on for a couple /// of pet types. Revisit only if that turns out to matter. /// /// See PetFollowPatch.cs (added 2026-08-28, after the Dog wandered off in-game and a second /// one wouldn't summon) for the leash-back-to-owner behavior and the ownership cleanup that /// runs when a tracked pet dies or its chunk unloads - both plug directly into this file's /// LimitedPets/AddOwnedEntity machinery. The manual recall path added here calls /// PetFollowPatch.Unregister so a recalled pet stops being tracked immediately instead of /// waiting for that cleanup to notice it's gone. /// /// DEBUG LOGGING: added 2026-08-28 after the Zombie Dog silently failed to spawn in-game /// (root cause was unrelated to this patch - see items.xml's AnimWait comment - but there was /// no logging anywhere in this file to even rule that out quickly, unlike CharmPatch.cs's /// verbose Debug.Log on every step). Keeping these permanently, same as CharmPatch.cs does. /// [HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn", new System.Type[] { typeof(ItemActionData) })] public static class Patch_ItemActionSpawnEntity_Spawn_PetLimit { public class PetInfo { public string AlreadyActiveKey; public string RecalledKey; public string NothingToRecallKey; public string SummonItemName; /// User request 2026-08-28: "пусть книга вообще не тратится на призыв /// собаки" - the Dog's book is a permanent bonded item, never consumed on summon (and /// RecallPet never has anything to give back, since nothing was ever taken). The /// Swarm keeps consuming its book per cast - it's the one-time/no-recall species, that /// consumption is the actual "cost" of casting it. This single flag also happens to /// fix the earlier "dog died, no way to get the book back" complaint: with nothing /// ever taken, there's nothing to lose when the dog dies off-screen. public bool ConsumesBook; /// Указание 2026-09-18, пока только для Духа крысы: "если крыса уже /// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом /// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было. /// Разбор прицела - в PetCommandPatch.cs. public bool CommandsAttack; /// Что показать, когда команда атаки отдана, но под прицелом некого /// атаковать. Читается только при CommandsAttack. public string NoTargetKey; /// Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено. /// /// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок. /// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок /// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у /// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет /// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в /// State.Home и летит обратно СВОИМ полётом: /// /// if (state != State.Home && --homeCheckDelay <= 0) { /// homeCheckDelay = 60; /// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop); /// } /// ... StartHome(getHomePosition().position.ToVector3()); /// /// Достаточно каждую секунду переставлять этот дом на позицию хозяина /// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без /// единого телепорта и без нашего пафайндинга. public int FlyingHomeRadius; /// Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и /// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в /// текстуру шкуры, доставать их нечем. public bool LitEyes; /// За сколько блоков от хозяина держится этот питомец. Ноль означает "как /// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку /// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус /// больше, чтобы туша не наступала хозяину на пятки. public float FollowDistance; /// Вешать ли этому питомцу собственную задачу следования /// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки, /// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для /// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в /// разные стороны. public bool UsesFollowTask; } public static readonly Dictionary LimitedPets = new Dictionary { { "necroZombieDog", new PetInfo { AlreadyActiveKey = "necroZombieDogAlreadyActive", RecalledKey = "necroZombieDogRecalled", NothingToRecallKey = "necroZombieDogNothingToRecall", SummonItemName = "bookSummonZombieDog", ConsumesBook = false, LitEyes = true, // 2026-09-18: "поведение других призванных животных зомби тоже подгони под // крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать // цель под прицелом, своя задача следования вместо телепорт-поводка. // AlreadyActiveKey у всех четверых теперь не читается никогда. CommandsAttack = true, NoTargetKey = "necroPetNoTarget", UsesFollowTask = true, } }, { // User request 2026-08-28: "пусть вызов насекомых будет одноразовым" - no // Action1 on bookSummonInsectSwarm any more (see items.xml), so // indexInEntityOfAction can never be RecallActionIndex for this species and // RecalledKey/NothingToRecallKey below are simply never read. Left null rather // than pointed at deleted localization keys. "necroInsectSwarm", new PetInfo { AlreadyActiveKey = "necroInsectSwarmAlreadyActive", RecalledKey = null, NothingToRecallKey = null, SummonItemName = "bookSummonInsectSwarm", ConsumesBook = true, } }, // Three more pets, BACKLOG.md item 4a. REPLACED 2026-08-29 - the original // Stripper/Cop/Soldier (extending real EntityZombie-classed vanilla zombies) came // back hostile to the player in testing despite copying the Dog's own recipe; see // entityclasses.xml for the full story. New concept: Зомбогриф/Зомбомедведь/ // Зомбоволк, extending animal-family bases (same category as the Dog itself, which // DID work) - same shape otherwise (recallable, book never consumed). { "necroZombieGriffin", new PetInfo { AlreadyActiveKey = "necroZombieGriffinAlreadyActive", RecalledKey = "necroZombieGriffinRecalled", NothingToRecallKey = "necroZombieGriffinNothingToRecall", SummonItemName = "bookSummonZombieGriffin", ConsumesBook = false, // 15 блоков: заметно меньше 32-метрового поводка, чтобы тот вообще не // понадобился, и достаточно, чтобы Гриф не висел у игрока над головой. FlyingHomeRadius = 15, // UsesFollowTask у Грифа НЕТ с 18.09: он снова летающий (EntityVulture), а // задача следования наземная - она гоняла бы его пафайндером по земле и // дралась бы с его собственным полётом. Приказ атаковать оставлен. // 2026-09-18: "поведение других призванных животных зомби тоже подгони под // крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать // цель под прицелом, своя задача следования вместо телепорт-поводка. // AlreadyActiveKey у всех четверых теперь не читается никогда. CommandsAttack = true, NoTargetKey = "necroPetNoTarget", } }, { "necroZombieBear", new PetInfo { AlreadyActiveKey = "necroZombieBearAlreadyActive", RecalledKey = "necroZombieBearRecalled", NothingToRecallKey = "necroZombieBearNothingToRecall", SummonItemName = "bookSummonZombieBear", ConsumesBook = false, FollowDistance = 3f, // 2026-09-18: "поведение других призванных животных зомби тоже подгони под // крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать // цель под прицелом, своя задача следования вместо телепорт-поводка. // AlreadyActiveKey у всех четверых теперь не читается никогда. CommandsAttack = true, NoTargetKey = "necroPetNoTarget", UsesFollowTask = true, } }, { "necroZombieWolf", new PetInfo { AlreadyActiveKey = "necroZombieWolfAlreadyActive", RecalledKey = "necroZombieWolfRecalled", NothingToRecallKey = "necroZombieWolfNothingToRecall", SummonItemName = "bookSummonZombieWolf", ConsumesBook = false, FollowDistance = 3f, // 2026-09-18: "поведение других призванных животных зомби тоже подгони под // крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать // цель под прицелом, своя задача следования вместо телепорт-поводка. // AlreadyActiveKey у всех четверых теперь не читается никогда. CommandsAttack = true, NoTargetKey = "necroPetNoTarget", UsesFollowTask = true, } }, // "Дух крысы", указание 2026-09-18 - питомец начального уровня (Некромантия 3) и // первый, у кого поведение задано намеренно, а не унаследовано от зомбопса. На нём // отлаживается ИИ питомцев вообще, поэтому он единственный, у кого стоят оба новых // флага. AlreadyActiveKey у него НЕ ЧИТАЕТСЯ НИКОГДА: повторный Action0 - это не // отказ "уже призван", а команда атаковать (CommandsAttack), так что вместо него // показывается либо ничего (приказ принят), либо NoTargetKey. { "necroRatSpirit", new PetInfo { AlreadyActiveKey = null, RecalledKey = "necroRatSpiritRecalled", NothingToRecallKey = "necroRatSpiritNothingToRecall", SummonItemName = "bookSummonRatSpirit", ConsumesBook = false, CommandsAttack = true, NoTargetKey = "necroPetNoTarget", UsesFollowTask = true, } }, }; /// Action1 ("power attack" slot) is always recall-only - see items.xml, both /// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0. public const int RecallActionIndex = 1; /// Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn. /// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут /// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат /// false из префикса его не отменяет), а делать им надо противоположное - в одном случае /// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что /// поставил игрок. public static bool summonAllowedThisCall; public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData) { summonAllowedThisCall = false; if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips)) { return true; } EntityAlive holdingEntity = _actionData?.invData?.holdingEntity; if (holdingEntity == null) { return true; } // BUG FIXED 2026-08-28 (unlimited summons, recall never ran, dogs pile-launching the // player): this used to check "<= 0" for "not found", on the wrong assumption that // valid ids are small positive numbers. Confirmed by decompiling EntityClass.GetId: // it returns -1 (a clean sentinel) when not found, and otherwise the real class id - // which is hash-based and can absolutely be negative (necroZombieDog's is, e.g., // -779816341, confirmed by this method's own Debug.Log below during the actual bug). // With "<= 0", that real, valid, negative id was misread as "not found" on every // single call, so this returned true unconditionally - which skipped BOTH the // summon-limit check AND the entire recall branch below (recall's check for it is // also past this point), so every Action0 OR Action1 click just summoned yet another // pet, forever, book and all. int petClassId = EntityClass.GetId(__instance.entityToSpawn); if (petClassId == -1) { Debug.LogWarning("[NecromancerTome] SummonPatch: entity class '" + __instance.entityToSpawn + "' not found"); return true; } List owned = holdingEntity.GetOwnedEntities(petClassId); Debug.Log("[NecromancerTome] SummonPatch: Spawn prefix for " + __instance.entityToSpawn + ", action index=" + _actionData.indexInEntityOfAction + ", owned count=" + owned.Count); if (_actionData.indexInEntityOfAction == RecallActionIndex) { if (owned.Count > 0) { RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null); } else if (holdingEntity.world != null) { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey); } return false; } if (owned.Count > 0) { // Action0 при уже живом питомце. Для всех, кроме Духа крысы, это тупик "уже // призван". Для него - команда атаковать то, на что смотрит игрок: см. // PetCommandPatch.cs, там же и весь отсев (торговцы, игроки, мёртвые). if (tooltips.CommandsAttack) { EntityAlive pet = holdingEntity.world != null ? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive : null; if (!PetAttackCommand.TryOrderAttack(holdingEntity, pet) && holdingEntity.world != null) { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NoTargetKey); } return false; } if (holdingEntity.world != null) { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey); } return false; } summonAllowedThisCall = true; return true; } /// ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком. /// Последней строкой ваниль делает вот что: /// /// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600); /// /// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и /// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не /// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа. /// /// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает /// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего. /// /// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после /// команды атаковать - то есть приказ гасил бы сам себя. public static void Postfix(ItemActionSpawnEntity __instance) { if (!summonAllowedThisCall) { return; } summonAllowedThisCall = false; if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo petInfo) || !petInfo.UsesFollowTask) { return; } World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null || world.EntityAlives == null) { return; } int petClassId = EntityClass.GetId(__instance.entityToSpawn); for (int i = world.EntityAlives.Count - 1; i >= 0; i--) { EntityAlive candidate = world.EntityAlives[i]; if (candidate != null && candidate.entityClass == petClassId && candidate.GetAttackTarget() != null) { candidate.SetAttackTarget(null, 0); Debug.Log("[NecromancerTome] SummonPatch: cleared the target vanilla Spawn handed to fresh pet " + candidate.entityId + " (this species never attacks on its own)"); } } } /// summonItemName is null when this species doesn't consume its book on summon /// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back. public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName) { World world = owner.world; if (world != null) { world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed); } owner.RemoveOwnedEntity(petEntityId); PetFollowPatch.Unregister(petEntityId); if (summonItemName != null) { GiveBackSummonItem(owner, summonItemName); } if (world != null) { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey); } Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " recalled pet " + petEntityId); } /// Hands one copy of the summon book back to whoever just recalled their pet - /// the dog/swarm "goes back into the book" literally, not just for free. Tries the /// toolbelt first (Inventory.AddItem - confirmed by decompiling it, only searches the /// toolbelt's own slots), then the backpack (EntityPlayer.bag, same AddItem shape) if that /// didn't fit. If both are full the book is just lost - not worth building actual overflow /// handling (a "drop it on the ground" fallback) for something this minor. public static void GiveBackSummonItem(EntityAlive owner, string itemName) { ItemValue itemValue = ItemClass.GetItem(itemName); if (itemValue == null || itemValue.type <= 0) { Debug.LogWarning("[NecromancerTome] SummonPatch: could not resolve item '" + itemName + "' to give back on recall"); return; } ItemStack stack = new ItemStack(itemValue, 1); bool added = owner.inventory != null && owner.inventory.AddItem(stack, out int slot); if (!added && owner is EntityPlayer player && player.bag != null) { // Bag (InventoryBase) only exposes the single-arg AddItem overload, unlike // Inventory's (ItemStack, out int) - confirmed by decompiling both. added = player.bag.AddItem(stack); } if (!added) { Debug.LogWarning("[NecromancerTome] SummonPatch: " + itemName + " didn't fit back into " + owner.entityId + "'s inventory on recall (full?)"); } } } /// /// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 2026-09-18. Первая /// (Patch_..._IsActionRunning_FreeTheRecall ниже) сняла блокировку вторичного действия /// основным, и этого оказалось мало: "отозвать собаку получилось далеко не с первого раза. /// Это проблема всех питомцев". /// /// ОСТАВШАЯСЯ ПРИЧИНА - ТА ЖЕ САМАЯ СЕМЬЯ, что и у призыва, только в другом слоте. Отзыв идёт /// через ItemActionSpawnEntity.Spawn, а до него надо ДОЖИТЬ через автомат состояний: /// /// OnHoldingUpdate: stateTime += 0.05 /// Anim: если stateTime >= animWait -> state = Spawn /// Spawn: Spawn(data); state = End /// ExecuteAction(_bReleased: true): state = None // отпустил - всё сбросилось /// /// При animWait = 0.1 это ТРИ тика: 0.05 (мало), 0.10 (переход в Spawn), и только третий /// вызывает сам Spawn. То есть кнопку надо продержать около 0.15 секунды. У призыва это /// лечилось уменьшением animWait до 0.05, но там и оставалось два тика; меньше двух автомат /// не умеет в принципе - один переход и одно исполнение. /// /// Поэтому отзыв вынут из автомата совсем: он делается прямо на НАЖАТИИ, в префиксе /// ExecuteAction, и оригинал не запускается вовсе. Никакого удержания, никакого ожидания. /// /// ЗАЩЁЛКА ОБЯЗАТЕЛЬНА. ExecuteAction(_bReleased: false) зовётся не один раз за клик, а /// КАЖДЫЙ КАДР, пока кнопка нажата (PlayerMoveController проверяет Secondary.IsPressed, а не /// WasPressed), и вся защита от повторов у ванили держится на её же state == None, который мы /// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и /// следом залило экран надписью "не призван" по разу в кадр. /// [HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")] public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall { /// Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого /// клика и заведомо меньше осмысленного повторного нажатия. public const float RepeatGuardSeconds = 0.5f; public static float lastRecallTime; public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData, bool _bReleased) { if (_bReleased || _actionData == null || _actionData.indexInEntityOfAction != Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallActionIndex) { return true; } if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue( __instance.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo)) { return true; } EntityAlive holdingEntity = _actionData.invData != null ? _actionData.invData.holdingEntity : null; if (holdingEntity == null || holdingEntity.world == null) { return true; } if (Time.time - lastRecallTime < RepeatGuardSeconds) { // Кнопка всё ещё зажата с прошлого кадра - молча проглатываем. return false; } int petClassId = EntityClass.GetId(__instance.entityToSpawn); if (petClassId == -1) { return true; } lastRecallTime = Time.time; List owned = holdingEntity.GetOwnedEntities(petClassId); if (owned.Count > 0) { Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id, petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null); holdingEntity.PlayOneShot(__instance.soundWarn); } else { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey); } return false; } } /// /// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку /// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза". /// /// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает, /// пускать ли вторичное действие, так: /// /// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions(); /// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт" /// ... /// if (flag12 && flag15 && (flag16 || !flag18)) // flag15 = Secondary.IsPressed /// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв /// /// То есть пока основное действие считается идущим, вторичное просто не запускается. /// А "идущим" оно считается вот по чему (ItemActionSpawnEntity): /// /// public override bool IsActionRunning(ItemActionData _actionData) /// => ((ItemActionDataSpawnEntity)_actionData).state != State.None; /// /// Состояние проходит None -> Anim -> Spawn -> **End**, и в None возвращается ТОЛЬКО из /// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже /// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок /// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0 /// висит именно в End, - и первый отзыв уходит в никуда. /// /// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт". /// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует /// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл /// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только /// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт /// основного по-прежнему заперт настоящим полем state, которое так и осталось End. /// [HarmonyPatch(typeof(ItemActionSpawnEntity), "IsActionRunning")] public static class Patch_ItemActionSpawnEntity_IsActionRunning_FreeTheRecall { public static void Postfix(ItemActionSpawnEntity __instance, ItemActionData _actionData, ref bool __result) { if (!__result || _actionData == null) { return; } if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(__instance.entityToSpawn)) { return; } if (_actionData is ItemActionSpawnEntity.ItemActionDataSpawnEntity data && data.state == ItemActionSpawnEntity.ItemActionDataSpawnEntity.State.End) { __result = false; } } } [HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })] public static class Patch_EntityFactory_CreateEntity_PetOwnership { public static void Postfix(int _et, Entity __result) { if (__result == null) { return; } Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo = null; foreach (KeyValuePair entry in Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets) { if (EntityClass.GetId(entry.Key) == _et) { petInfo = entry.Value; break; } } if (petInfo == null) { return; } Debug.Log("[NecromancerTome] SummonPatch: CreateEntity postfix, entity=" + __result.entityId + " et=" + _et); World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null || world.Players == null || world.Players.list == null) { return; } EntityPlayer owner = null; float bestDistSq = float.MaxValue; foreach (EntityPlayer player in world.Players.list) { if (player == null) { continue; } float distSq = (player.position - __result.position).sqrMagnitude; if (distSq < bestDistSq) { bestDistSq = distSq; owner = player; } } if (owner == null) { Debug.LogWarning("[NecromancerTome] SummonPatch: no player found to own " + __result.entityId); return; } owner.AddOwnedEntity(__result); if (petInfo.ConsumesBook && owner.inventory != null) { owner.inventory.DecHoldingItem(1); } // Первая попытка развести коллайдеры - здесь же, чтобы в удачном случае не ждать // секунду до тика. Удачной она, скорее всего, НЕ БУДЕТ: модель питомца на этом шаге // ещё не собрана и коллайдеров у него нет (разбор - в TryIgnoreCollisionWithOwner). // Поэтому результат передаётся в Register, и тик повторяет попытку до успеха. bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result); PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes, petInfo.FlyingHomeRadius); AttachFollowTask(owner, __result, petInfo); // Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил // альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не // поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный // вид всем питомцам теперь даёт GhostTraderPatch.cs - та же система, что у торговцев, // с клонированием материала, обесцвечиванием и подбором blend-шейдера. Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId); } /// Вешает питомцу собственную задачу следования - указание 2026-09-18, пока /// только Духу крысы (PetInfo.UsesFollowTask). /// /// ЧЕРЕЗ КОД, А НЕ ЧЕРЕЗ XML - см. шапку PetFollowTask.cs: XML-путь существует, но упирается /// в Type.GetType с именем сборки, а этот не упирается ни во что и заодно позволяет отдать /// владельца прямо в поле задачи, без поиска по ownedEntities на каждом тике. /// /// Приоритет 3: у крысы в entityclasses.xml объявлены AITask-1 (ApproachAndAttackTarget) и /// AITask-2 (Look), а AITask-3 пустой - то есть следование встаёт ровно туда, где в XML /// кончился список, и ниже погони за целью. Разводить их приоритетом при этом всё равно /// недостаточно: задача следования сама отказывается работать, пока у питомца есть цель /// (NecroFollowOwnerTask.CanExecute), и несёт те же MutexBits=3, что ванильный /// EAIApproachSpot. public static void AttachFollowTask(EntityPlayer owner, Entity pet, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo) { if (!petInfo.UsesFollowTask) { return; } EntityAlive alive = pet as EntityAlive; if (alive == null || alive.aiManager == null || alive.aiManager.tasks == null) { Debug.LogWarning("[NecromancerTome] SummonPatch: pet " + pet.entityId + " has no aiManager - follow task NOT attached"); return; } NecroFollowOwnerTask task = new NecroFollowOwnerTask { OwnerEntityId = owner.entityId }; if (petInfo.FollowDistance > 0f) { task.SlotDistance = petInfo.FollowDistance; } task.Init(alive); alive.aiManager.tasks.AddTask(3, task); Debug.Log("[NecromancerTome] SummonPatch: follow task attached to pet " + pet.entityId + " (owner " + owner.entityId + ")"); } } }