ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале

Работа по указаниям 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>
This commit is contained in:
AlexCube
2026-09-18 20:51:42 +03:00
co-authored by Claude Opus 5
parent 15c59dd37a
commit d97f83e972
21 changed files with 2501 additions and 179 deletions
+299 -2
View File
@@ -53,6 +53,27 @@ namespace NecromancerTome
{
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;
}
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
@@ -62,12 +83,98 @@ namespace NecromancerTome
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)
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
bool wantsLitEyes, int flyingHomeRadius)
{
TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId });
TrackedPets.Add(new TrackedPet
{
OwnerEntityId = owner.entityId,
PetEntityId = pet.entityId,
OwnFollowTask = ownFollowTask,
CollisionIgnored = collisionIgnored,
WantsLitEyes = wantsLitEyes,
FlyingHomeRadius = flyingHomeRadius,
});
}
/// <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
@@ -83,6 +190,130 @@ namespace NecromancerTome
}
}
/// <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;
@@ -147,6 +378,72 @@ namespace NecromancerTome
}
}
// Повторная попытка развести коллайдеры - до первого успеха. Стоит ДО всех
// проверок про бой и расстояние: "питомец подбрасывает хозяина" не зависит ни от
// того, ни от другого.
if (!tracked.CollisionIgnored)
{
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
}
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 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.