Работа по указаниям 2026-09-18. В релиз пока не выходит. НОВЫЙ ПИТОМЕЦ "Дух крысы" (necroRatSpirit), Некромантия 3 (60 убийств): модель зомбоволка в масштабе 0.28, урон 5, кроличьи звуки. Сам не нападает никогда, держится справа-сзади в двух блоках, упёршись в препятствие проходит СКВОЗЬ него и блоков не ломает. Укус замедляет, оставляет метку духа (видна на компасе, +25% получаемого урона) и рвёт жилу. Обгрызая труп, лечится. СВОЯ ЗАДАЧА ИИ. Задачи "иди за сущностью" в игре нет вовсе - проверены все 32 типа EAI*. Написана NecroFollowOwnerTask по образцу EAIApproachSpot: сектор "3-6 часов" от хозяина, FindPath с canBreak:false, проход сквозь препятствие через Entity.IsStuck. Вешается в рантайме, минуя Type.GetType. ПРИКАЗ АТАКОВАТЬ. Повторное применение свитка при живом питомце натравливает его на цель под прицелом: EntityPlayerLocal.HitInfo + ItemActionAttack. GetEntityFromHit. Торговцы и игроки отсеяны. Нет цели - "Нет цели для атаки". ЗОМБОЖИВОТНЫЕ ПРИВЕДЕНЫ К ТОМУ ЖЕ ОБРАЗЦУ. Сняты BreakBlock, Territorial, ApproachSpot, Wander, BlockingTargetTask и поедание трупов; цель они больше не выбирают сами. Лестница урона 20/35/45/60 плюс расчленение у Пса, Медведя и Волка. Кровотечение всем, метка и ослабление - только у крысы. УБИЙСТВА ПИТОМЦЕМ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ, включая добивание кровотечением. Префикс на AwardKillXPServer подменяет убийцу владельцем; для смерти от баффа заведена память укусов, потому что в DamageSource от баффа нет того, кто его наложил. Зомби под Камнем духов это не задело - решение от 17.09 в силе. ГРИФ откачен на летающую ветку EntityVulture и переименован в Могильного стервятника: модель наконец соответствует имени. Держится у игрока сам, через собственный механизм "дома" (setHomeArea), на время погони дом отвязывается. Попытка натянуть птичий префаб на наземный класс провалилась и записана - так делать нельзя. ПРОЧЕЕ: призрачный вид распространён с торговцев на питомцев (у Пса, Медведя и Волка выключен по указанию), у Пса светятся фиолетовые глаза, белая иконка книги снята со всех свитков призыва, "Жуки Властелина" переименованы в "Рой фараона" на всех 13 языках. ПОРОГИ: крыса 60, стервятник 500, пёс 1300, медведь 2000, волк 4000. ИСПРАВЛЕНО ПО ХОДУ ИГРОВЫХ ПРОВЕРОК: питомцы не призывались обычным кликом (AnimWait требовал удержания), отзыв срабатывал не с первого раза (автомат состояний ItemActionSpawnEntity), крыса проваливалась сквозь мир (IsStuck отключает и пол), питомец подбрасывал хозяина (коллайдеры разводились до появления модели). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
685 lines
38 KiB
C#
685 lines
38 KiB
C#
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 && --homeCheckDelay <= 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;
|
||
}
|
||
|
||
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",
|
||
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,
|
||
}
|
||
},
|
||
};
|
||
|
||
/// <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)
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <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>
|
||
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);
|
||
}
|
||
|
||
/// <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;
|
||
|
||
List<OwnedEntityData> 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 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.
|
||
/// </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;
|
||
}
|
||
}
|
||
}
|
||
|
||
[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);
|
||
AttachFollowTask(owner, __result, petInfo);
|
||
// Полупрозрачность больше НЕ делается здесь. Бывший 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 + ")");
|
||
}
|
||
}
|
||
}
|