Files
necromants-tome-7d2d-3-2/HarmonySrc/SummonPatch.cs
T
AlexCubeandClaude Opus 5.5 beab1640b0 Одержимость питомцев, стервятник-разведчик, метки на карте, череп, глава письма
- Одержимость питомца: ПКМ с книгой - вселиться, E - отозвать (и изнутри питомца),
  пояс скрыт, надпись о враге у тела.
- Одержимый стервятник: полёт, рывок на ЛКМ с 10 м с прямым уроном, звуком и
  кровотечением, обычный прицел краснеет на цели; посадка в позу 0 и взлёт на пробел;
  ПКМ - клич-приманка (паникующая курица, откат 0.5 с); F - питаться трупами и
  блоками-падалью, доеденное исчезает.
- Дух змеи атакует только нежить; фиолетовая метка на карте у змеи и одержимого.
- Череп из красного трупа (этап 1 цинерария); глава книги «Письмо архимага» с видео.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:09:28 +03:00

816 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>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.</summary>
public bool ConsumesBook;
/// <summary>Указание 2026-09-18, пока только для Духа крысы: "если крыса уже
/// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом
/// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было.
/// Разбор прицела - в PetCommandPatch.cs.</summary>
public bool CommandsAttack;
/// <summary>Что показать, когда команда атаки отдана, но под прицелом некого
/// атаковать. Читается только при CommandsAttack.</summary>
public string NoTargetKey;
/// <summary>Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено.
///
/// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок.
/// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок
/// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у
/// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет
/// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в
/// State.Home и летит обратно СВОИМ полётом:
///
/// if (state != State.Home &amp;&amp; --homeCheckDelay &lt;= 0) {
/// homeCheckDelay = 60;
/// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop);
/// }
/// ... StartHome(getHomePosition().position.ToVector3());
///
/// Достаточно каждую секунду переставлять этот дом на позицию хозяина
/// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без
/// единого телепорта и без нашего пафайндинга.</summary>
public int FlyingHomeRadius;
/// <summary>Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и
/// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в
/// текстуру шкуры, доставать их нечем.</summary>
public bool LitEyes;
/// <summary>За сколько блоков от хозяина держится этот питомец. Ноль означает "как
/// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку
/// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус
/// больше, чтобы туша не наступала хозяину на пятки.</summary>
public float FollowDistance;
/// <summary>Вешать ли этому питомцу собственную задачу следования
/// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки,
/// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
/// разные стороны.</summary>
public bool UsesFollowTask;
/// <summary>Охраняет ли питомец хозяина сам (PetFollowPatch.Guard) - указание 30.09.2026:
/// «охраняют все, кроме крысы». Рой сюда не входит: он не спутник, а разовое заклинание
/// со своим поиском целей.</summary>
public bool Guards;
}
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
{
{
"necroZombieDog",
new PetInfo
{
AlreadyActiveKey = "necroZombieDogAlreadyActive",
RecalledKey = "necroZombieDogRecalled",
NothingToRecallKey = "necroZombieDogNothingToRecall",
SummonItemName = "bookSummonZombieDog",
ConsumesBook = false,
LitEyes = true,
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
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",
Guards = true,
}
},
{
"necroZombieBear",
new PetInfo
{
AlreadyActiveKey = "necroZombieBearAlreadyActive",
RecalledKey = "necroZombieBearRecalled",
NothingToRecallKey = "necroZombieBearNothingToRecall",
SummonItemName = "bookSummonZombieBear",
ConsumesBook = false,
FollowDistance = 3f,
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
Guards = true,
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",
Guards = true,
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,
}
},
};
/// <summary>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.</summary>
public const int RecallActionIndex = 1;
/// <summary>Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn.
/// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут
/// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат
/// false из префикса его не отменяет), а делать им надо противоположное - в одном случае
/// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что
/// поставил игрок.</summary>
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<OwnedEntityData> 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)
{
// С 06.10 ПКМ - одержимость питомца (Patch_..._ExecuteAction_InstantRecall делает её на
// нажатии, сюда доходит только если та пропустила); отзыв - на E (PetRecallKey).
TryPossessPet(holdingEntity, owned, tooltips);
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;
}
RecallOtherCompanions(holdingEntity, __instance.entityToSpawn, tooltips);
summonAllowedThisCall = true;
return true;
}
/// <summary>ОДИН ПИТОМЕЦ ЗА РАЗ - указание 30.09.2026: «за один раз можно было призвать только
/// одного питомца. А то слишком жирно сразу и собаку, и крысу, и стервятника, и медведя».
/// Раньше предел был по одному НА ВИД, и все пятеро могли ходить вместе.
///
/// Сделано заменой, а не отказом: призыв нового сам отзывает прежнего. Игроку не надо
/// искать в поясе другую книгу, чтобы отозвать, и новых строк перевода не нужно - о прежнем
/// сообщает его же готовая надпись «… отозван».
///
/// Спутник - это вид с отзывом (RecalledKey). Рой Жуков не спутник: он разовое заклинание
/// без отзыва, сам никого не вытесняет, и его не вытесняют.</summary>
public static void RecallOtherCompanions(EntityAlive _owner, string _summoned, PetInfo _summonedInfo)
{
if (_summonedInfo.RecalledKey == null)
{
return;
}
foreach (KeyValuePair<string, PetInfo> entry in LimitedPets)
{
if (entry.Key == _summoned || entry.Value.RecalledKey == null)
{
continue;
}
int classId = EntityClass.GetId(entry.Key);
if (classId == -1)
{
continue;
}
List<OwnedEntityData> others = _owner.GetOwnedEntities(classId);
for (int i = others.Count - 1; i >= 0; i--)
{
Debug.Log("[NecromancerTome] SummonPatch: один питомец за раз - " + entry.Key + " " + others[i].Id +
" отозван ради " + _summoned);
RecallPet(_owner, others[i].Id, entry.Value.RecalledKey, null);
}
}
}
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
/// Последней строкой ваниль делает вот что:
///
/// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600);
///
/// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и
/// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не
/// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа.
///
/// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает
/// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего.
///
/// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после
/// команды атаковать - то есть приказ гасил бы сам себя.</summary>
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)");
}
}
}
/// <summary>summonItemName is null when this species doesn't consume its book on summon
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
/// <summary>ПКМ с книгой призыва (указание 06.10): вселиться в призванного питомца этой книги.
/// Нет питомца - надпись necroPetNothingToPossess.</summary>
public static void TryPossessPet(EntityAlive holdingEntity, List<OwnedEntityData> owned, PetInfo tooltips)
{
EntityPlayerLocal local = holdingEntity as EntityPlayerLocal;
if (local == null || tooltips.RecalledKey == null)
{
return;
}
EntityAlive pet = owned.Count > 0 && holdingEntity.world != null ? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive : null;
if (pet == null || pet.IsDead())
{
GameManager.ShowTooltip(local, "necroPetNothingToPossess");
return;
}
if (!Possession.EnterPet(local, pet))
{
Debug.Log("[NecromancerTome] SummonPatch: вселиться в питомца " + pet.entityId + " нельзя (змея, чертоги, транспорт или уже одержимость)");
}
}
/// <summary>Отозвать питомца книги, которую держит игрок (E, PetRecallKey).</summary>
public static void RecallHeldBookPet(EntityAlive holdingEntity, string entityToSpawn, PetInfo tooltips)
{
int petClassId = EntityClass.GetId(entityToSpawn);
if (petClassId == -1)
{
return;
}
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
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);
}
}
/// <summary>E в одержимом питомце (тест 06.10: «E не сработал, срабатывает R») - отзыв его самого:
/// игрок возвращается в тело, питомец уходит в книгу. HandleInteraction во время одержимости не
/// идёт (PlayerMoveController.Update выключен), поэтому зовёт Possession. True - отозван.</summary>
public static bool RecallPossessedPet(EntityAlive owner, EntityAlive pet)
{
if (owner == null || pet == null || !EntityClass.list.TryGetValue(pet.entityClass, out EntityClass ec) ||
!LimitedPets.TryGetValue(ec.entityClassName, out PetInfo info) || info.RecalledKey == null)
{
return false;
}
RecallPet(owner, pet.entityId, info.RecalledKey, info.ConsumesBook ? info.SummonItemName : null);
return true;
}
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
{
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
{
Possession.Exit("питомец отозван");
}
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);
}
/// <summary>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.</summary>
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?)");
}
}
}
/// <summary>
/// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 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, который мы
/// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и
/// следом залило экран надписью "не призван" по разу в кадр.
/// </summary>
[HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")]
public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall
{
/// <summary>Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого
/// клика и заведомо меньше осмысленного повторного нажатия.</summary>
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;
// С 06.10 ПКМ - одержимость питомца, отзыв переехал на E (PetRecallKey).
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
Patch_ItemActionSpawnEntity_Spawn_PetLimit.TryPossessPet(holdingEntity, owned, petInfo);
return false;
}
}
/// <summary>
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку
/// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза".
///
/// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает,
/// пускать ли вторичное действие, так:
///
/// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions();
/// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт"
/// ...
/// if (flag12 &amp;&amp; flag15 &amp;&amp; (flag16 || !flag18)) // flag15 = Secondary.IsPressed
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
///
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
///
/// public override bool IsActionRunning(ItemActionData _actionData)
/// =&gt; ((ItemActionDataSpawnEntity)_actionData).state != State.None;
///
/// Состояние проходит None -&gt; Anim -&gt; Spawn -&gt; **End**, и в None возвращается ТОЛЬКО из
/// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже
/// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок
/// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0
/// висит именно в End, - и первый отзыв уходит в никуда.
///
/// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт".
/// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует
/// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл
/// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только
/// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт
/// основного по-прежнему заперт настоящим полем state, которое так и осталось End.
/// </summary>
[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;
}
}
}
/// <summary>
/// ОТЗЫВ НА E - указание 06.10: «Отозвать питомца снимаем с ПКМ и ставим на E» (ПКМ отдана
/// одержимости питомца). E у игрока - «взаимодействие»: PlayerMoveController.HandleInteraction
/// получает _activate и открывает то, что под прицелом (блок, предмет, сущность). Отзываем только
/// когда под прицелом взаимодействовать не с чем - иначе E с книгой в руке перестала бы открывать
/// двери и ящики. «Не с чем» = у игры нет подсказки «Нажмите E…» (strTextLabelPointingTo пуст: на
/// кадре нажатия она не обновляется и держит прошлый кадр - то, куда игрок смотрел).
/// </summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.HandleInteraction))]
public static class Patch_PlayerMoveController_HandleInteraction_PetRecallKey
{
public static void Postfix(PlayerMoveController __instance, bool _activate)
{
if (!_activate || Possession.Active || !string.IsNullOrEmpty(__instance.strTextLabelPointingTo))
{
return;
}
EntityPlayerLocal player = __instance.entityPlayerLocal;
if (player == null || player.AttachedToEntity != null || player.IsDead() || player.inventory == null)
{
return;
}
ItemClass held = player.inventory.holdingItem;
if (held?.Actions == null || held.Actions.Length == 0 || !(held.Actions[0] is ItemActionSpawnEntity spawn) ||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(spawn.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo info) ||
info.RecalledKey == null)
{
return;
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallHeldBookPet(player, spawn.entityToSpawn, info);
player.PlayOneShot(spawn.soundWarn);
}
}
[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<string, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo> 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, petInfo.Guards);
AttachFollowTask(owner, __result, petInfo);
// Указание 06.10: при призыве ненадолго - «Отозвать питомца - E». Рой не отзывается.
if (petInfo.RecalledKey != null && owner is EntityPlayerLocal ownerLocal)
{
GameManager.ShowTooltip(ownerLocal, "necroPetRecallHint");
}
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
// вид всем питомцам теперь даёт GhostTraderPatch.cs - та же система, что у торговцев,
// с клонированием материала, обесцвечиванием и подбором blend-шейдера.
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
}
/// <summary>Вешает питомцу собственную задачу следования - указание 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.</summary>
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 + ")");
}
}
}