using GamePath;
using UnityEngine;
namespace NecromancerTome
{
///
/// ПЕРВАЯ СВОЯ ЗАДАЧА ИИ В ЭТОМ МОДЕ. "Дух крысы" держится справа-сзади от хозяина, а не
/// бродит сам по себе - указание 2026-09-18 ("держится сбоку и позади хозяина на 2 блока...
/// если по циферблату часов, то на 3-6 часов (зависит от препятствий)").
///
/// ПОЧЕМУ ЭТО ПРИШЛОСЬ ПИСАТЬ, А НЕ НАСТРОИТЬ. Задачи "иди за сущностью" в игре НЕТ вообще -
/// проверено по списку типов в Assembly-CSharp: всего 32 класса EAI*, и ни одного
/// follow-подобного (EAIWander, EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget,
/// EAISetNearestCorpseAsTarget и т.д.). Слежение дрона за хозяином - это захардкоженный C#
/// внутри EntityDrone, в XML его не выбрать. До сегодняшнего дня мод обходился телепорт-
/// поводком в PetFollowPatch.cs: раз в секунду, если питомец дальше 32 м, его просто
/// переставляли к игроку. Это и есть то, что игроки видели как "питомец не идёт следом".
///
/// ОБРАЗЕЦ ВЗЯТ С ВАНИЛЬНОГО EAIApproachSpot - он решает ровно ту же задачу "дойти до точки"
/// и показывает весь нужный обвес: theEntity.FindPath(...) -> theEntity.navigator.getPath()
/// -> moveHelper. Отличий от него три, и все три - требования пользователя:
///
/// 1. ТОЧКА ПЕРЕСЧИТЫВАЕТСЯ КАЖДЫЙ ТИК и привязана к хозяину, а не к месту на земле.
/// SlotAngles - сектор "3-6 часов" от направления взгляда хозяина, перебираемый от
/// середины к краям: сначала 135° (ровно между бортом и кормой), потом ближе к борту и к
/// корме. Первая точка, до которой нашёлся путь, и становится местом крысы - это и есть
/// "зависит от препятствий".
///
/// 2. canBreak: false в FindPath. Питомцы до сих пор носили ванильный AITask BreakBlock и
/// грызли всё на пути, включая базу игрока. Крыса не может грызть блоки НА УРОВНЕ
/// ПАФАЙНДЕРА: он просто не строит путей через разрушение. В entityclasses.xml у неё
/// заодно нет ни BreakBlock, ни BlockingTargetTask.
///
/// 3. ПРОХОД СКВОЗЬ БЛОКИ вместо разрушения - указание "технически, дух может проходить
/// сквозь блоки, если нужно пройти там где есть препятствие. Блоки ломать не нужно".
/// Для этого не понадобилось ничего изобретать: в Entity есть публичное поле IsStuck, и
/// единственное место, где игра его читает, - собственный шаг перемещения:
///
/// if (IsStuck) { PhysicsTransform.position += hitMove; } // мимо контроллера
/// else { collisionFlags = m_characterController.Move(hitMove); }
///
/// То есть это готовый выключатель столкновений, оставленный игрой себе на случай
/// застревания. Ни один из просмотренных классов (EntityMoveHelper, EntityZombie,
/// EntityEnemy, EntityAnimal, EntityAlive, EntityPlayerLocal) в него не ПИШЕТ - поле
/// фактически бесхозное, и мы можем владеть им единолично.
///
/// ОПАСНОСТЬ ЗДЕСЬ РЕАЛЬНАЯ, И ОНА УЖЕ СРАБОТАЛА: IsStuck отключает столкновения со
/// ВСЕМ, включая пол, и 18.09 крыса провалилась под мир ("fell off the world,
/// pos=(319.2, -0.4, 1367.2)"). Разбор и все пять страховок - в комментариях к
/// MaxDropBelowOwner и в UpdatePhase; коротко: гравитация гасится на каждом тике,
/// ниже цели питомец не опускается, проход не включается на дальней дистанции и
/// ограничен по времени, а падение ловится отдельной проверкой в начале Update.
///
/// ЗАДАЧА ДОБАВЛЯЕТСЯ В РАНТАЙМЕ (SummonPatch.cs), а не через XML. В XML это тоже возможно -
/// EAIManager.GetType() для незнакомого имени падает в Type.GetType("EAI" + имя), и
/// индексированные AITask-N берут значение свойства целиком, так что
/// AITask-N value="NecroFollowOwner,NecromancerHarmony" разрешилось бы в наш тип. Но
/// Type.GetType с именем сборки дёргает Assembly.Load, а как именно загрузчик модов кладёт
/// наши DLL - непроверено, и ставить работоспособность питомца в зависимость от этого нет
/// никакой нужды: aiManager.tasks.AddTask даёт тот же результат без единого допущения, да ещё
/// и позволяет передать владельца прямо в конструктор.
///
public class NecroFollowOwnerTask : EAIBase
{
/// Сектор "3-6 часов" в градусах от направления взгляда хозяина: 90° - правый
/// борт, 180° - корма. Перебор идёт от середины сектора к его краям, чтобы обычное место
/// крысы было одно и то же, а края работали как запасные при препятствии.
public static readonly float[] SlotAngles = { 135f, 112f, 158f, 90f, 180f };
/// Расстояние по умолчанию - два блока от хозяина.
public const float DefaultSlotDistance = 2f;
/// Своё у каждого питомца: Медведю и Волку задан блок дальше остальных
/// (указание 2026-09-18 - "сделай место следования там же где и собаке, но на блок
/// дальше, они мощнее"). Сектор при этом общий, меняется только радиус, поэтому
/// крупные звери встают в тот же угол, но не наступают хозяину на пятки.
/// Значение приходит из SummonPatch.LimitedPets при создании задачи.
public float SlotDistance = DefaultSlotDistance;
/// Ближе этого крыса считает, что уже на месте, и останавливается. Квадрат
/// расстояния, как везде в этом коде - корень тут не нужен.
public const float ArrivedDistSq = 1.2f * 1.2f;
/// Столько секунд без сокращения расстояния - и точка считается недостижимой:
/// пробуем следующую, а когда кончатся все - проходим насквозь.
///
/// ПОДНЯТО С 1.5 ДО 3 СЕКУНД 2026-09-18, после того как крыса провалилась сквозь мир.
/// Полторы секунды - слишком мало: в помещении питомец столько обходит мебель, не
/// сокращая расстояния ПО ПРЯМОЙ, и проход включался там, где обычный обход дошёл бы
/// сам. Чем реже поднимается IsStuck, тем меньше поводов у всего, что с ним связано.
public const float NoProgressSeconds = 3f;
/// Метры в секунду на проходе сквозь препятствие. Медленнее бега: дух
/// просачивается, а не выстреливает.
public const float PhaseSpeed = 3.5f;
/// Предохранитель. Если за это время проход не закончился, он прекращается
/// принудительно - лучше крыса, стоящая не там, чем крыса, летящая сквозь мир.
public const float PhaseTimeout = 4f;
/// Дальше этого расстояния до хозяина ждать пафайндер бессмысленно - прыжок.
/// Та же цифра, что у старого поводка в PetFollowPatch.cs (и у ванильного DroneManager,
/// откуда она изначально и взята).
///
/// РАНЬШЕ ЗДЕСЬ БЫЛ ПРОХОД, И ЭТО БЫЛА ОШИБКА (исправлено 18.09, см. MaxDropBelowOwner):
/// проход задуман для препятствия в шаге, а не для перелёта через тридцать метров
/// незнакомой местности с отключёнными столкновениями.
public const float LeashDistance = 32f;
/// Аварийный предел по высоте: если питомец оказался ниже хозяина больше чем на
/// столько, разбираться уже некогда - прыжок к хозяину.
///
/// ЗАЧЕМ. 18.09 крыса пропала в игре, и лог сказал ровно что случилось:
/// WRN Entity [type=EntityZombieDog, name=necroRatSpirit, id=1459] fell off the world,
/// pos=(319.2, -0.4, 1367.2)
/// Высота -0.4: она провалилась под мир, а игра удаляет всё, что опустилось ниже нуля
/// (Entity.onUpdate: position.y < 0f -> MarkToUnload). Виноват IsStuck - он снимает
/// столкновения со ВСЕМ, включая пол, а гравитация никуда не девается: шаг перемещения
/// складывает hitMove с накопленным motion. Четыре секунды прохода над дыркой в полу -
/// и питомца нет.
///
/// Эта проверка работает независимо от прохода и ловит любое падение, а не только наше
/// собственное.
public const float MaxDropBelowOwner = 6f;
/// Ниже этой высоты вмешиваемся в любом случае: мир кончается на нуле, и пять
/// метров запаса нужны, чтобы успеть что-то сделать, а не констатировать.
public const float WorldFloorGuard = 5f;
public int OwnerEntityId = -1;
public Vector3 seekPos;
public int slotIndex;
public float noProgressTime;
public float lastDistSq;
public bool phasing;
public float phaseTime;
public int pathRecalculateTicks;
public override void Init(EntityAlive _theEntity)
{
base.Init(_theEntity);
// 3 - те же биты, что у EAIApproachSpot: это задача ПЕРЕМЕЩЕНИЯ, и она не должна
// исполняться одновременно с другой такой же (в первую очередь с погоней за целью).
MutexBits = 3;
executeDelay = 0.1f;
}
public EntityAlive Owner()
{
if (OwnerEntityId < 0 || theEntity == null || theEntity.world == null)
{
return null;
}
return theEntity.world.GetEntity(OwnerEntityId) as EntityAlive;
}
/// Точка для слота slotIndex: хозяин + поворот его собственного направления на
/// угол из SlotAngles, посаженный на опорный блок.
public Vector3 SlotPosition(EntityAlive _owner, int _slot)
{
return SlotPosition(_owner, _slot, theEntity.world, SlotDistance);
}
/// То же самое, но без экземпляра задачи - нужна PetFollowPatch.cs, который
/// спасает упавших питомцев во время боя, когда сама задача не исполняется. Там точный
/// радиус не важен (питомца просто возвращают к хозяину), поэтому берётся общий.
public static Vector3 SlotPosition(EntityAlive _owner, int _slot, World _world,
float _slotDistance = DefaultSlotDistance)
{
float angle = SlotAngles[_slot % SlotAngles.Length];
Vector3 dir = _owner.qrotation * (Quaternion.Euler(0f, angle, 0f) * Vector3.forward);
Vector3 raw = _owner.position + dir * _slotDistance;
return _world.FindSupportingBlockPos(raw);
}
public override bool CanExecute()
{
// Дерётся - не мешаем. Цель крысе даёт игрок (PetCommandPatch.cs), и пока она есть,
// место в строю подождёт.
if (theEntity.GetAttackTarget() != null)
{
return false;
}
if (theEntity.IsDead() || theEntity.IsSleeping)
{
return false;
}
EntityAlive owner = Owner();
if (owner == null)
{
return false;
}
seekPos = SlotPosition(owner, slotIndex);
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
}
public override void Start()
{
noProgressTime = 0f;
lastDistSq = float.MaxValue;
phasing = false;
phaseTime = 0f;
pathRecalculateTicks = 0;
}
public override bool Continue()
{
if (theEntity.GetAttackTarget() != null)
{
return false;
}
EntityAlive owner = Owner();
if (owner == null)
{
return false;
}
seekPos = SlotPosition(owner, slotIndex);
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
}
public override void Update()
{
EntityAlive owner = Owner();
if (owner == null)
{
return;
}
// ПЕРВОЙ СТРОКОЙ, ДО ВСЕГО ОСТАЛЬНОГО: не падает ли питомец из мира. Проверка стоит
// здесь, а не внутри прохода, намеренно - провалиться можно и без нашего участия
// (дыра в полу POI, взорванный блок), а последствие одно и то же: сущность ниже нуля
// игра удаляет молча, и питомец просто исчезает.
if (theEntity.position.y < WorldFloorGuard ||
theEntity.position.y < owner.position.y - MaxDropBelowOwner)
{
SnapToOwner(owner, "падение (высота " + theEntity.position.y.ToCultureInvariantString("0.0") + ")");
return;
}
Vector3 lookPosition = owner.position;
lookPosition.y += 0.8f;
theEntity.SetLookPosition(lookPosition);
if (phasing)
{
UpdatePhase();
return;
}
float distSq = (seekPos - theEntity.position).sqrMagnitude;
bool ownerFarAway = (owner.position - theEntity.position).sqrMagnitude > LeashDistance * LeashDistance;
// Сокращаем расстояние - значит путь есть и он работает; счётчик простоя сбрасывается.
if (distSq < lastDistSq - 0.05f)
{
noProgressTime = 0f;
}
else
{
noProgressTime += executeDelay;
}
lastDistSq = distSq;
// ХОЗЯИН ДАЛЕКО - ЭТО НЕ ПОВОД ПРОХОДИТЬ СКВОЗЬ СТЕНЫ, и раньше было именно так.
// Проход задуман для препятствия В ШАГЕ, а не для перелёта через тридцать метров
// незнакомой местности с отключёнными столкновениями: ровно такой проход 18.09 и
// уронил крысу под мир. На дальней дистанции работает обычный прыжок поводком - тот
// самый приём, которым ванильный DroneManager возвращает дрона, и он безопасен,
// потому что точка назначения известна заранее и стоит на земле.
if (ownerFarAway)
{
SnapToOwner(owner, "хозяин дальше " + LeashDistance + " м");
return;
}
if (noProgressTime >= NoProgressSeconds)
{
noProgressTime = 0f;
lastDistSq = float.MaxValue;
if (slotIndex >= SlotAngles.Length - 1)
{
// Перепробованы все точки сектора - значит дело в препятствии: сквозь.
slotIndex = 0;
BeginPhase();
return;
}
// Следующая точка сектора - "зависит от препятствий".
slotIndex++;
seekPos = SlotPosition(owner, slotIndex);
pathRecalculateTicks = 0;
}
if (theEntity.navigator.getPath() != null)
{
theEntity.moveHelper.CalcIfUnreachablePos();
}
if (--pathRecalculateTicks <= 0)
{
UpdatePath();
}
}
public void UpdatePath()
{
if (PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
{
return;
}
pathRecalculateTicks = 8 + GetRandom(6);
// canBreak: false - крыса не грызёт блоки, это её главное отличие от всех остальных
// питомцев мода (см. шапку файла).
theEntity.FindPath(seekPos, theEntity.GetMoveSpeedAggro(), false, this);
}
public void BeginPhase()
{
phasing = true;
phaseTime = 0f;
theEntity.moveHelper.Stop();
theEntity.IsStuck = true;
}
public void EndPhase()
{
phasing = false;
phaseTime = 0f;
theEntity.IsStuck = false;
// ОБЯЗАТЕЛЬНО: пока IsStuck был поднят, пола для крысы не существовало. Сажаем её на
// опорный блок, иначе она останется висеть или продолжит падать.
theEntity.SetPosition(theEntity.world.FindSupportingBlockPos(theEntity.position), true);
lastDistSq = float.MaxValue;
noProgressTime = 0f;
pathRecalculateTicks = 0;
}
public void UpdatePhase()
{
phaseTime += executeDelay;
// ГРАВИТАЦИЯ ГАСИТСЯ КАЖДЫЙ ТИК, и это главная правка 18.09. Пока IsStuck поднят,
// шаг перемещения складывает накопленный motion прямо с позицией, минуя контроллер
// и пол, - то есть питомец продолжает "падать" между нашими SetPosition, причём
// ускоряясь. Обнуление motion означает: во время прохода питомца двигает ТОЛЬКО
// этот метод и ничто больше.
theEntity.motion = Vector3.zero;
Vector3 next = Vector3.MoveTowards(theEntity.position, seekPos, PhaseSpeed * executeDelay);
// И отдельно - высота. Даже с обнулённым motion спускаться ниже цели незачем:
// seekPos уже посажен на опорный блок (FindSupportingBlockPos), так что всё, что
// ниже него, - это пол, сквозь который мы проходить не собирались.
if (next.y < seekPos.y)
{
next.y = seekPos.y;
}
theEntity.SetPosition(next, true);
if (phaseTime >= PhaseTimeout || (next - seekPos).sqrMagnitude <= ArrivedDistSq)
{
EndPhase();
}
}
/// Прыжок к хозяину: аварийный выход и он же дальний поводок. Точка - тот же
/// слот в секторе, посаженный на опорный блок, а не позиция хозяина в упор (иначе
/// коллайдеры сложатся и питомец подбросит игрока - эти грабли уже разобраны в
/// PetFollowPatch.cs).
public void SnapToOwner(EntityAlive _owner, string _why)
{
if (phasing)
{
phasing = false;
phaseTime = 0f;
}
theEntity.IsStuck = false;
theEntity.motion = Vector3.zero;
theEntity.moveHelper.Stop();
slotIndex = 0;
lastDistSq = float.MaxValue;
noProgressTime = 0f;
pathRecalculateTicks = 0;
Vector3 dest = SlotPosition(_owner, 0);
theEntity.SetPosition(dest, true);
Debug.Log("[NecromancerTome] NecroFollowOwnerTask: pet " + theEntity.entityId +
" snapped back to owner " + _owner.entityId + " - " + _why);
}
public override void Reset()
{
if (phasing)
{
EndPhase();
}
theEntity.IsStuck = false;
theEntity.moveHelper.Stop();
theEntity.SetLookPosition(Vector3.zero);
}
public override string ToString()
{
return string.Format("{0}, slot{1}{2} dist{3}", base.ToString(), slotIndex,
phasing ? " PHASING" : "",
(theEntity.position - seekPos).magnitude.ToCultureInvariantString());
}
}
}