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()); } } }