Files
necromants-tome-7d2d-3-2/HarmonySrc/PetFollowPatch.cs
T
AlexCubeandClaude Opus 5.5 f2dfeed126 1.7.0: охрана питомцами, один питомец за раз, прицел книг, гео-оссуарий
Питомцы:
- охрана хозяина: враг ближе 20 м (в режиме змеи - к телу) атакуется сам,
  поводок 40 м, охраняют все кроме крысы; приказ прицелом охрана не перебивает
- IsFoe исключает существ мода (necro*): питомец выбирал целью себя
- призыв нового спутника отзывает прежнего
- прицел книг призыва - как у арбалета, краснеет на годной цели

Гео-оссуарий (necroGeoOssuary): станция добычи на кровавой сфере
- основа - коллектор росы; в 3 ячейки модов любой из 5 ресурсов
  (камень, уголь, селитра, железо, сланец), 1 сфера = стак 6000 за 5-8 ч
- работает без открытого неба; верстак, Подмастерье (ур. 25)
- своя модель (бандл necroossuary): кости, корни, сосуды, клыки, черепа
  с глазами-углями; шейдер и теги коллайдера берутся у ванильного коллектора

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:36:45 +03:00

609 lines
32 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 UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Keeps a summoned pet (see SummonPatch.cs) from wandering off and getting permanently
/// lost/unsummonable - user report 2026-08-28: the Zombie Dog didn't follow like a drone
/// would, wandered off (heard, not seen), and a second summon attempt did nothing while the
/// first was presumably still out there somewhere.
///
/// Two separate problems, one fix:
///
/// 1. "Doesn't follow": there is no generic "follow a specific entity" AI task anywhere in
/// this game - confirmed by listing every EAI*-named type in Assembly-CSharp (EAIWander,
/// EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget, etc. - nothing
/// follow-shaped). EntityDrone's follow behavior is hardcoded C# specific to that one
/// class, not something an XML entity_class can opt into. Writing a real custom AITask
/// (actual pathfinding, priority-tuned against the pet's existing Wander/Territorial/
/// ApproachSpot tasks) is real engineering the backlog didn't ask for - so this copies
/// vanilla's own fallback for the identical problem instead: DroneManager.Update()
/// teleports a drone back near its owner once it's more than 32m away (sqrMagnitude >
/// 1024) and not doing something else (OrderState != Stay) - confirmed by decompiling
/// it. Same threshold, same idea, generalized to any pet class instead of drones only,
/// and skipped while the pet has a live attack target (don't yank it out of a fight).
///
/// 2. "Doesn't reappear on retry": SummonPatch.cs's one-pet-per-species limit checks
/// EntityAlive.ownedEntities, but nothing was ever removing a pet from that list once it
/// died or its chunk unloaded - unlike the drone, where DroneManager's own death/unload
/// callbacks do that cleanup. A dead or vanished pet left the slot "occupied" forever.
/// The same periodic check below detects a tracked pet that's gone (world.GetEntity
/// returns null - dead, or its chunk unloaded and it despawned like any untracked
/// entity, per the known persistence gap documented in SummonPatch.cs) and clears
/// ownership so the player can summon a fresh one.
///
/// 3. (added 2026-08-28) Insect Swarm only: makes it move on to the next zombie once its
/// current target is already Deviator-charmed, instead of camping the same converted
/// zombie forever - see the inline comment below, right where it happens.
///
/// Hooked via ModEvents.UnityUpdate - the same supported per-frame mod event GameManager's own
/// gmUpdate() fires DroneManager.Update() from (confirmed by decompiling GameManager) - not a
/// Harmony patch, since this is a genuine public extension point, no reason to patch around
/// it. Throttled to run the real check once a second (CheckInterval): a distance/liveness
/// check on a handful of pets is cheap, but no reason to do it 60x/sec either.
/// </summary>
public static class PetFollowPatch
{
public const float LeashDistance = 32f; // matches DroneManager's own 32m leash
public const float LeashDistanceSq = LeashDistance * LeashDistance;
public const float CheckInterval = 1f;
public class TrackedPet
{
public int OwnerEntityId;
public int PetEntityId;
/// <summary>У питомца своя задача следования (NecroFollowOwnerTask, 2026-09-18) -
/// значит телепорт-поводок ниже его НЕ трогает. Уборка владения при этом остаётся:
/// она к способу передвижения отношения не имеет.</summary>
public bool OwnFollowTask;
/// <summary>Удалось ли развести коллайдеры питомца с хозяйскими. Пока false, попытка
/// повторяется каждый тик - см. TryIgnoreCollisionWithOwner.</summary>
public bool CollisionIgnored;
/// <summary>Зажжены ли глаза (PetEyeGlowPatch.cs). Как и коллайдеры, с первого раза
/// обычно не получается - модели ещё нет; попытка повторяется до успеха.</summary>
public bool EyesLit;
/// <summary>Нужно ли им вообще заниматься: копия PetInfo.LitEyes, чтобы тик не лазил
/// в словарь на каждом питомце каждую секунду.</summary>
public bool WantsLitEyes;
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
/// питомец не летающий, дом ему не переставляем.</summary>
public int FlyingHomeRadius;
/// <summary>Охраняет ли хозяина сам - копия PetInfo.Guards.</summary>
public bool Guards;
/// <summary>На кого игрок указал прицелом (PetAttackCommand). Пока эта цель жива и питомец
/// занят именно ею, охрана его не перенацеливает. -1 - приказа нет.</summary>
public int OrderedTargetId = -1;
/// <summary>Питомец ушёл дальше GuardLeash и возвращается: пока он не подойдёт к хозяину
/// на GuardRange, любые цели с него снимаются - иначе укус в спину разворачивал бы его.</summary>
public bool Returning;
}
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
public static float timer;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
}
/// <summary>ДОБАВЛЕНО 2026-09-18. TrackedPets - статический список, он переживает выход в
/// меню, а вот мир, к которому относятся лежащие в нём entityId, - нет. Без этой уборки
/// на следующей загрузке первый же тик начинал разбирать чужие идентификаторы: в новом
/// мире тот же номер принадлежит совершенно другой сущности, и RemoveOwnedEntity уходил
/// бы неизвестно куда. GhostTraderPatch.cs подписан на то же событие ровно по той же
/// причине; здесь подписки не было - это был найденный, но не закрытый пробел из разбора
/// ИИ питомцев (BACKLOG.md, 2026-09-18, п. 6).</summary>
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
{
if (TrackedPets.Count > 0)
{
Debug.Log("[NecromancerTome] PetFollowPatch: world shutting down, forgetting " +
TrackedPets.Count + " tracked pet(s)");
TrackedPets.Clear();
}
timer = 0f;
}
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
bool wantsLitEyes, int flyingHomeRadius, bool guards)
{
TrackedPets.Add(new TrackedPet
{
OwnerEntityId = owner.entityId,
PetEntityId = pet.entityId,
OwnFollowTask = ownFollowTask,
CollisionIgnored = collisionIgnored,
WantsLitEyes = wantsLitEyes,
FlyingHomeRadius = flyingHomeRadius,
Guards = guards,
});
}
/// <summary>Разводит коллайдеры питомца и его хозяина, чтобы они проходили друг сквозь
/// друга. Возвращает true, только если РЕАЛЬНО что-то развела.
///
/// БАГ-РЕПОРТ 2026-09-18: "когда крыса оказывается под ногами, игрока подбрасывает".
/// Просьба сделать питомца нематериальным для игрока была ещё 28.08, и код для неё
/// написан тогда же (SummonPatch.IgnoreCollisionWithOwner) - но он вызывался ровно один
/// раз, из постфикса на EntityFactory.CreateEntity, а это СЛИШКОМ РАНО: там сущность
/// только создана и в мир ещё не добавлена (SpawnEntityInWorld идёт следующей строкой в
/// ItemActionSpawnEntity.Spawn), модель не собрана, и коллайдеров, привязанных к костям
/// через PhysicsBody, попросту ещё нет. GetComponentsInChildren возвращал пустой массив,
/// цикл не делал ни одной итерации, и никто этого не замечал - метод ничего не возвращал
/// и ничего не логировал. Ровно тот же урок про "модель собирается позже" уже записан в
/// GhostTraderPatch.cs, где из-за него пришлось опрашивать торговцев по таймеру.
///
/// Поэтому теперь попытка повторяется в тике раз в секунду, пока не удастся. Толкать
/// игрока питомец сможет в худшем случае одну секунду после призыва.
///
/// Разводятся ВСЕ пары коллайдеров, включая капсулы передвижения: CharacterController -
/// это тоже Collider, и именно на ней игрок и стоит, когда его подбрасывает.</summary>
public static bool TryIgnoreCollisionWithOwner(EntityAlive owner, Entity pet)
{
if (owner == null || pet == null)
{
return false;
}
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>(true);
Collider[] petColliders = pet.GetComponentsInChildren<Collider>(true);
if (ownerColliders == null || petColliders == null ||
ownerColliders.Length == 0 || petColliders.Length == 0)
{
return false;
}
int pairs = 0;
foreach (Collider oc in ownerColliders)
{
if (oc == null)
{
continue;
}
foreach (Collider pc in petColliders)
{
if (pc == null || pc == oc)
{
continue;
}
Physics.IgnoreCollision(oc, pc, true);
pairs++;
}
}
if (pairs > 0)
{
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " passes through owner " +
owner.entityId + " now (" + ownerColliders.Length + "x" + petColliders.Length +
" collider pairs ignored)");
}
return pairs > 0;
}
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
/// tracked immediately, instead of lingering until the next tick notices it's gone.</summary>
public static void Unregister(int petEntityId)
{
for (int i = TrackedPets.Count - 1; i >= 0; i--)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
TrackedPets.RemoveAt(i);
}
}
}
/// <summary>ОХРАНА ХОЗЯИНА - указание 30.09.2026. Образ: «ночь, мы в виде змеи ползаем на
/// другом конце карты, а тело охраняет стервятник». До этого питомец дрался только по приказу
/// (PetAttackCommand) - отзыв sanovskiy 23.09: «приказ нужен на каждую цель».
///
/// Решения пользователя по пунктам:
/// - радиус охраны 20 м от хозяина (GuardRange); в режиме змеи хозяин - это лежащее тело,
/// позиция игрока там и остаётся;
/// - охраняют все, кроме крысы (PetInfo.Guards);
/// - отходить можно до 40 м (GuardLeash), дальше - возвращается;
/// - бить ближайшего к телу, если не было приказа атаковать конкретного.
///
/// Враг - тот же, что для тревоги у тела в режиме змеи (SnakeControl.IsFoe): живой EntityEnemy,
/// не питомец и не девиант. СПЯЩИЕ НЕ В СЧЁТ: иначе питомец, проходящий сквозь стены, будил бы
/// всё здание, пока игрок крадётся, - к спящему слово «приближается» не относится.
///
/// Уже начатую цель охрана не бросает, пока та в радиусе: выбор «ближайшего» заново каждую
/// секунду заставил бы питомца метаться между двумя зомби и не добить ни одного.</summary>
public const float GuardRange = 20f;
public const float GuardLeash = 40f;
public static void NoteOrder(int petEntityId, int targetEntityId)
{
for (int i = 0; i < TrackedPets.Count; i++)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
TrackedPets[i].OrderedTargetId = targetEntityId;
TrackedPets[i].Returning = false;
}
}
}
public static EntityAlive NearestFoe(World world, Vector3 center, float range)
{
EntityAlive nearest = null;
float best = range * range;
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive e = world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, null) || e.IsSleeping)
{
continue;
}
float distSq = (e.position - center).sqrMagnitude;
if (distSq < best)
{
best = distSq;
nearest = e;
}
}
return nearest;
}
public static void Guard(TrackedPet tracked, EntityAlive pet, EntityAlive owner, World world)
{
if (owner.IsDead())
{
return;
}
float fromOwnerSq = (pet.position - owner.position).sqrMagnitude;
EntityAlive target = pet.GetAttackTarget();
// Поводок 40 м - для всех спутников, и для приказа игрока тоже.
if (tracked.Returning)
{
if (fromOwnerSq > GuardRange * GuardRange)
{
if (target != null)
{
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
}
return;
}
tracked.Returning = false;
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " вернулся к хозяину");
}
else if (fromOwnerSq > GuardLeash * GuardLeash)
{
tracked.Returning = true;
tracked.OrderedTargetId = -1;
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " ушёл на " +
Mathf.Sqrt(fromOwnerSq).ToCultureInvariantString("0") + " м от хозяина - цель снята, возвращается");
return;
}
if (!tracked.Guards)
{
return;
}
bool live = target != null && !target.IsDead();
if (live && target.entityId == tracked.OrderedTargetId)
{
return;
}
tracked.OrderedTargetId = -1;
if (live && SnakeControl.IsFoe(target, null) &&
(target.position - owner.position).sqrMagnitude <= GuardRange * GuardRange)
{
return;
}
EntityAlive foe = NearestFoe(world, owner.position, GuardRange);
if (foe == null || foe == target)
{
return;
}
pet.SetRevengeTarget(null);
pet.SetAttackTarget(foe, PetAttackCommand.OrderTicks);
Debug.Log("[NecromancerTome] PetFollowPatch: охрана - pet " + pet.entityId + " идёт на " + foe.entityId +
" (" + foe.EntityClass.entityClassName + "), до хозяина " +
(foe.position - owner.position).magnitude.ToCultureInvariantString("0.0") + " м");
}
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
///
/// Поедание тут не отдельное поведение, а побочный эффект приказа: команда атаковать ставит
/// цель на 6000 тиков (PetAttackCommand.OrderTicks), а EAIApproachAndAttackTarget.CanExecute
/// сверяет только ТИП цели и ничего не знает про её смерть - так что, добив зомби, крыса
/// продолжает грызть труп, пока тот не исчезнет. Само по себе это выглядело хорошо, поэтому
/// не чинится, а используется.
///
/// Лечение идёт отсюда, а не из triggered_effect в items.xml, по простой причине: в XML
/// нельзя спросить "цель мертва". Полный список классов Requirement* в Assembly-CSharp
/// просмотрен - там есть RequirementFullHealth, RequirementHasEntityTag, RequirementNearbyEntities
/// и ещё сорок, но ни одного про смерть цели. Значит гейт всё равно оказался бы в коде, а
/// тик раз в секунду тут уже есть и обходит ровно тех же питомцев.
///
/// Условий три, и все три обязательны: цель есть, цель МЕРТВА (иначе это лечение в бою, а
/// не питание), и крыса рядом с ней (иначе она лечилась бы, стоя в другом конце улицы и
/// только СОБИРАЯСЬ дойти до трупа).</summary>
public const float FeedRangeSq = 2.5f * 2.5f;
/// <summary>Здоровья за секунду поедания. У Духа крысы всего 120 HP, так что 5 - это полное
/// восстановление примерно за полминуты над одним трупом: заметно, но не бесплатно.</summary>
public const int FeedHealthPerSecond = 5;
/// <summary>ДЫРА, ЗАКРЫТАЯ 2026-09-18. Защита от падения живёт в NecroFollowOwnerTask, а
/// та НЕ ИСПОЛНЯЕТСЯ, пока у питомца есть цель (CanExecute возвращает false) - то есть
/// именно в бою, когда питомец и бегает по незнакомым местам, ловить его было нечем.
/// Этот тик работает всегда, поэтому проверка переехала сюда.
///
/// ПРОВЕРКИ ДВЕ, И ОНИ ПРО РАЗНОЕ - это важно не путать.
///
/// 1. АБСОЛЮТНАЯ ВЫСОТА. Ниже отметки 5 не бывает законных причин находиться: мир
/// кончается на нуле, и всё, что туда опустилось, игра удаляет молча
/// (Entity.onUpdate: position.y &lt; 0f -&gt; MarkToUnload). Двусмысленности нет никакой,
/// поэтому спасаем всегда, хоть в бою, хоть нет. Это и есть настоящий детектор
/// падения, и он один закрывает тот случай, на котором 18.09 потерялась крыса.
///
/// 2. НИЖЕ ЦЕЛИ (предложено пользователем). Это детектор НЕ падения, а
/// НЕДОСТИЖИМОСТИ: питомец, который на десять метров ниже того, кого ему велели
/// грызть, скорее всего не упал, а не может добраться - зомби на крыше, на этаже
/// выше, за проломом. Без этой проверки он будет ломиться туда вечно: приказ игрока
/// держится 6000 тиков и сам не истечёт.
///
/// Поэтому здесь цель ещё и СБРАСЫВАЕТСЯ. Вернуть питомца к хозяину, не сняв
/// приказ, значило бы получить маятник: прыжок наверх - бег вниз - прыжок наверх.
///
/// Обратная сторона второй проверки названа честно: зомби, стоящий двумя этажами выше,
/// теперь отменяет приказ вместо бесконечной беготни. По-моему это лучше, но если
/// окажется, что питомец сдаётся слишком рано - крутить UnreachableBelowTarget.</summary>
public const float UnreachableBelowTarget = 10f;
public static void RescueFallen(EntityAlive pet, EntityAlive owner)
{
if (pet == null || owner == null || pet.world == null)
{
return;
}
bool outOfWorld = pet.position.y < NecroFollowOwnerTask.WorldFloorGuard;
EntityAlive target = pet.GetAttackTarget();
bool unreachable = !outOfWorld && target != null && !target.IsDead() &&
pet.position.y < target.position.y - UnreachableBelowTarget;
if (!outOfWorld && !unreachable)
{
return;
}
pet.IsStuck = false;
pet.motion = Vector3.zero;
if (pet.moveHelper != null)
{
pet.moveHelper.Stop();
}
if (unreachable)
{
pet.SetAttackTarget(null, 0);
}
pet.SetPosition(NecroFollowOwnerTask.SlotPosition(owner, 0, pet.world), true);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " rescued to owner " +
owner.entityId + " - " + (outOfWorld
? "выпал из мира (высота " + pet.position.y.ToCultureInvariantString("0.0") + ")"
: "цель недостижима, она выше на " +
(target.position.y - pet.position.y).ToCultureInvariantString("0.0") + " м"));
}
/// <summary>Кому принадлежит этот питомец. null - значит не наш или уже не отслеживается.
/// Нужна PetKillCreditPatch.cs, чтобы зачесть убийство владельцу.</summary>
public static EntityPlayer FindOwnerOfPet(int petEntityId)
{
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null)
{
return null;
}
for (int i = 0; i < TrackedPets.Count; i++)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
return world.GetEntity(TrackedPets[i].OwnerEntityId) as EntityPlayer;
}
}
return null;
}
public static void FeedOnCorpse(EntityAlive pet)
{
EntityAlive target = pet.GetAttackTarget();
if (target == null || !target.IsDead())
{
return;
}
if ((target.position - pet.position).sqrMagnitude > FeedRangeSq)
{
return;
}
int before = pet.Health;
pet.AddHealth(FeedHealthPerSecond);
if (pet.Health != before)
{
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " fed on corpse " +
target.entityId + ", health " + before + " -> " + pet.Health);
}
}
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
timer += Time.deltaTime;
if (timer < CheckInterval)
{
return;
}
timer = 0f;
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null)
{
return;
}
// Трупы, которые не удалось перекрасить в момент смерти (модель бывает ещё не
// собрана) - добиваем здесь: тик всё равно уже идёт. См. CorpseLootPatch.cs.
// ВЫШЕ проверки на пустой список питомцев намеренно: трупы помечает Нож некроманта,
// и к тому, есть ли у игрока сейчас призванный питомец, они отношения не имеют.
CorpseLoot.RetryPendingTints(world);
if (TrackedPets.Count == 0)
{
return;
}
for (int i = TrackedPets.Count - 1; i >= 0; i--)
{
TrackedPet tracked = TrackedPets[i];
EntityAlive pet = world.GetEntity(tracked.PetEntityId) as EntityAlive;
if (pet == null || pet.IsDead())
{
EntityAlive ownerForCleanup = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
if (ownerForCleanup != null)
{
ownerForCleanup.RemoveOwnedEntity(tracked.PetEntityId);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + tracked.PetEntityId + " gone, cleared ownership for " + ownerForCleanup.entityId);
}
TrackedPets.RemoveAt(i);
continue;
}
EntityAlive owner = world.GetEntity(tracked.OwnerEntityId) as EntityAlive;
if (owner == null)
{
// Owner not currently loaded (e.g. disconnected) - leave the pet tracked,
// nothing useful to do until they're back.
continue;
}
// User request 2026-08-28: "рой будет заражать зомби девиацией и лететь к
// следующему незаражённому?" - not on its own, so this makes it one. EntityVulture
// (the Swarm's real base class - see SwarmTargetPatch.cs) has no notion of
// "this target is already converted, go find another" - once it has a live target
// it just keeps attacking it until that target dies, charmed or not (repeated
// AddBuff on an already-charmed zombie is a harmless no-op, so it wasn't wrong,
// just stuck). Clearing the attack target here when it's already charmed makes
// EntityVulture's own FindTarget()/SetAttackTarget cycle kick back in on its next
// pass (SwarmTargetPatch.cs redirects that straight to the nearest zombie again,
// same as any other retarget) - not instant (that cycle runs on its own ~2s timer,
// not driven by us), but converts-then-moves-on within a couple seconds.
if (pet.entityClass == Patch_EntityAlive_SetAttackTarget_SwarmRetarget.SwarmOnlyClassId())
{
EntityAlive currentTarget = pet.GetAttackTarget();
if (currentTarget != null && currentTarget.Buffs != null && currentTarget.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
{
pet.SetAttackTarget(null, 0);
Debug.Log("[NecromancerTome] PetFollowPatch: swarm " + pet.entityId + " dropped already-charmed target " + currentTarget.entityId);
}
}
// Повторная попытка развести коллайдеры - до первого успеха. Стоит ДО всех
// проверок про бой и расстояние: "питомец подбрасывает хозяина" не зависит ни от
// того, ни от другого.
if (!tracked.CollisionIgnored)
{
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
}
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
{
Guard(tracked, pet, owner, world);
}
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
// пафайндинга, ни своей задачи - см. PetInfo.FlyingHomeRadius.
//
// НА ВРЕМЯ АТАКИ ДОМ ОТВЯЗЫВАЕТСЯ СОВСЕМ (указание того же дня: "для атаки радиус
// нужно увеличить, пусть летит сколько надо... а вот после атаки пусть
// возвращается"). Иначе собственная проверка EntityVulture срывала бы его с цели
// на полпути: она прерывает атаку, как только питомец вышел за радиус.
//
// Отвязка - это detachHome(), то есть maximumHomeDistance = -1, и тогда
// isWithinHomeDistanceCurrentPosition() безусловно возвращает true. Ограничителем
// вместо радиуса остаётся сама дальность приказа: цель назначается только по
// прицелу, а луч прицела не длиннее 30 метров, так что отправить Грифа на другой
// край карты нельзя при всём желании.
//
// Как только цель пропала (убита, недостижима, приказ сброшен), дом на следующей
// же секунде встаёт обратно на хозяина - и питомец возвращается сам.
if (tracked.FlyingHomeRadius > 0)
{
if (pet.GetAttackTarget() != null)
{
if (pet.hasHome())
{
pet.detachHome();
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
" is on a target - home detached for the chase");
}
}
else
{
if (!pet.hasHome())
{
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
" finished the chase - home re-anchored to owner " + owner.entityId);
}
pet.setHomeArea(new Vector3i(owner.position), tracked.FlyingHomeRadius);
}
}
if (tracked.WantsLitEyes && !tracked.EyesLit)
{
PetEyeGlow.DumpMaterialsOnce(pet, EntityClass.list[pet.entityClass]?.entityClassName);
tracked.EyesLit = PetEyeGlow.TryLightEyes(pet);
}
if (tracked.OwnFollowTask)
{
FeedOnCorpse(pet);
RescueFallen(pet, owner);
// У этого питомца есть настоящая задача следования (NecroFollowOwnerTask):
// она сама держит его при хозяине, сама решает, когда пройти сквозь стену, и
// сама сажает его на опорный блок. Телепорт-поводок здесь только мешал бы -
// две системы дёргали бы питомца в разные стороны. Уборка владения выше при
// этом уже отработала, и она остаётся общей для всех.
continue;
}
if (pet.GetAttackTarget() != null)
{
// Mid-fight - let it finish rather than teleporting it away.
continue;
}
float distSq = (pet.position - owner.position).sqrMagnitude;
if (distSq > LeashDistanceSq)
{
// 2m in front of the owner, not exactly on top of them - landing right on the
// owner's own position stacks the pet's collider into the player's and shoves
// them (this is exactly what happened during the "<=0 vs -1" bug above: a pile
// of a dozen undespawned dogs all teleporting onto the same point as the player
// every second launched them into the air).
Vector3 dest = owner.position + owner.qrotation * new Vector3(0f, 0f, 2f);
pet.SetPosition(dest, true);
Debug.Log("[NecromancerTome] PetFollowPatch: teleported pet " + pet.entityId + " back to owner " + owner.entityId);
}
}
}
}
}