Files
necromants-tome-7d2d-3-2/HarmonySrc/PetFollowTask.cs
T
AlexCubeandClaude Opus 5 d97f83e972 ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале
Работа по указаниям 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>
2026-09-18 20:51:42 +03:00

409 lines
22 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 GamePath;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// ПЕРВАЯ СВОЯ ЗАДАЧА ИИ В ЭТОМ МОДЕ. "Дух крысы" держится справа-сзади от хозяина, а не
/// бродит сам по себе - указание 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 даёт тот же результат без единого допущения, да ещё
/// и позволяет передать владельца прямо в конструктор.
/// </summary>
public class NecroFollowOwnerTask : EAIBase
{
/// <summary>Сектор "3-6 часов" в градусах от направления взгляда хозяина: 90° - правый
/// борт, 180° - корма. Перебор идёт от середины сектора к его краям, чтобы обычное место
/// крысы было одно и то же, а края работали как запасные при препятствии.</summary>
public static readonly float[] SlotAngles = { 135f, 112f, 158f, 90f, 180f };
/// <summary>Расстояние по умолчанию - два блока от хозяина.</summary>
public const float DefaultSlotDistance = 2f;
/// <summary>Своё у каждого питомца: Медведю и Волку задан блок дальше остальных
/// (указание 2026-09-18 - "сделай место следования там же где и собаке, но на блок
/// дальше, они мощнее"). Сектор при этом общий, меняется только радиус, поэтому
/// крупные звери встают в тот же угол, но не наступают хозяину на пятки.
/// Значение приходит из SummonPatch.LimitedPets при создании задачи.</summary>
public float SlotDistance = DefaultSlotDistance;
/// <summary>Ближе этого крыса считает, что уже на месте, и останавливается. Квадрат
/// расстояния, как везде в этом коде - корень тут не нужен.</summary>
public const float ArrivedDistSq = 1.2f * 1.2f;
/// <summary>Столько секунд без сокращения расстояния - и точка считается недостижимой:
/// пробуем следующую, а когда кончатся все - проходим насквозь.
///
/// ПОДНЯТО С 1.5 ДО 3 СЕКУНД 2026-09-18, после того как крыса провалилась сквозь мир.
/// Полторы секунды - слишком мало: в помещении питомец столько обходит мебель, не
/// сокращая расстояния ПО ПРЯМОЙ, и проход включался там, где обычный обход дошёл бы
/// сам. Чем реже поднимается IsStuck, тем меньше поводов у всего, что с ним связано.</summary>
public const float NoProgressSeconds = 3f;
/// <summary>Метры в секунду на проходе сквозь препятствие. Медленнее бега: дух
/// просачивается, а не выстреливает.</summary>
public const float PhaseSpeed = 3.5f;
/// <summary>Предохранитель. Если за это время проход не закончился, он прекращается
/// принудительно - лучше крыса, стоящая не там, чем крыса, летящая сквозь мир.</summary>
public const float PhaseTimeout = 4f;
/// <summary>Дальше этого расстояния до хозяина ждать пафайндер бессмысленно - прыжок.
/// Та же цифра, что у старого поводка в PetFollowPatch.cs (и у ванильного DroneManager,
/// откуда она изначально и взята).
///
/// РАНЬШЕ ЗДЕСЬ БЫЛ ПРОХОД, И ЭТО БЫЛА ОШИБКА (исправлено 18.09, см. MaxDropBelowOwner):
/// проход задуман для препятствия в шаге, а не для перелёта через тридцать метров
/// незнакомой местности с отключёнными столкновениями.</summary>
public const float LeashDistance = 32f;
/// <summary>Аварийный предел по высоте: если питомец оказался ниже хозяина больше чем на
/// столько, разбираться уже некогда - прыжок к хозяину.
///
/// ЗАЧЕМ. 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 &lt; 0f -&gt; MarkToUnload). Виноват IsStuck - он снимает
/// столкновения со ВСЕМ, включая пол, а гравитация никуда не девается: шаг перемещения
/// складывает hitMove с накопленным motion. Четыре секунды прохода над дыркой в полу -
/// и питомца нет.
///
/// Эта проверка работает независимо от прохода и ловит любое падение, а не только наше
/// собственное.</summary>
public const float MaxDropBelowOwner = 6f;
/// <summary>Ниже этой высоты вмешиваемся в любом случае: мир кончается на нуле, и пять
/// метров запаса нужны, чтобы успеть что-то сделать, а не констатировать.</summary>
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;
}
/// <summary>Точка для слота slotIndex: хозяин + поворот его собственного направления на
/// угол из SlotAngles, посаженный на опорный блок.</summary>
public Vector3 SlotPosition(EntityAlive _owner, int _slot)
{
return SlotPosition(_owner, _slot, theEntity.world, SlotDistance);
}
/// <summary>То же самое, но без экземпляра задачи - нужна PetFollowPatch.cs, который
/// спасает упавших питомцев во время боя, когда сама задача не исполняется. Там точный
/// радиус не важен (питомца просто возвращают к хозяину), поэтому берётся общий.</summary>
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();
}
}
/// <summary>Прыжок к хозяину: аварийный выход и он же дальний поводок. Точка - тот же
/// слот в секторе, посаженный на опорный блок, а не позиция хозяина в упор (иначе
/// коллайдеры сложатся и питомец подбросит игрока - эти грабли уже разобраны в
/// PetFollowPatch.cs).</summary>
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());
}
}
}