ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале
Работа по указаниям 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:
co-authored by
Claude Opus 5
parent
15c59dd37a
commit
d97f83e972
@@ -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 < 0f -> 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.
|
||||
|
||||
Reference in New Issue
Block a user