ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале
Работа по указаниям 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
@@ -266,6 +266,61 @@ namespace NecromancerTome
|
||||
timer = 0f;
|
||||
}
|
||||
|
||||
/// <summary>Кого этот проход обращает в призрака.
|
||||
///
|
||||
/// РАСШИРЕНО 2026-09-18 С ТОРГОВЦЕВ НА ПИТОМЦЕВ, по указанию: "всех призванных существ
|
||||
/// (зомбопёс, зомбомедведь, зомбоволк, зомбогриф) тоже делаем полупрозрачными как сейчас
|
||||
/// торговцев". Плюс Дух крысы, который призраком и задуман.
|
||||
///
|
||||
/// Обобщение вышло механическим, и это не совпадение: ApplyGreyscale ниже никогда не
|
||||
/// пользовался ничем трейдерским - только GetComponentsInChildren<Renderer>(),
|
||||
/// EntityClass.entityClassName и entityId, а всё это есть у любой EntityAlive.
|
||||
///
|
||||
/// СПИСОК БЕРЁТСЯ ИЗ SummonPatch.LimitedPets, а не дублируется здесь: новый питомец
|
||||
/// становится призраком автоматически, в одном месте, и разойтись эти два списка не могут.
|
||||
/// Рой жуков в нём тоже есть, но он безвреден - его рендереры это системы частиц, а
|
||||
/// ApplyGreyscale их намеренно пропускает (см. Convert), так что для него проход вхолостую.
|
||||
///
|
||||
/// Проверка по имени класса, а не по типу: все наземные питомцы мода - это C#-класс
|
||||
/// EntityZombieDog (см. entityclasses.xml), то есть по типу их не отличить ни друг от
|
||||
/// друга, ни от настоящего зомбопса из мира.</summary>
|
||||
/// <summary>Исключения из призрачного вида. Зомбопёс - по прямому указанию 2026-09-18:
|
||||
/// "зомбособаку не делай полупрозрачной или серой, пусть будет обычной".
|
||||
///
|
||||
/// Проверка идёт по ИМЕНИ КЛАССА, а не по типу, и это здесь важно: Дух крысы наследуется
|
||||
/// от necroZombieWolf, а тот от necroZombieDog, то есть по типу они все EntityZombieDog и
|
||||
/// исключение задело бы заодно и крысу, которая призраком как раз и задумана.</summary>
|
||||
public static readonly HashSet<string> NotGhosted = new HashSet<string>
|
||||
{
|
||||
"necroZombieDog",
|
||||
// Медведь и Волк добавлены 2026-09-18: "прозрачность им не нужна". Призрачными
|
||||
// остаются Дух крысы, Зомбогриф и торговцы.
|
||||
"necroZombieBear",
|
||||
"necroZombieWolf",
|
||||
// Гриф выведен 18.09 вместе с откатом: "не делай его прозрачным". На птичьей модели
|
||||
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
|
||||
// голова, что и выглядело жутко.
|
||||
"necroZombieGriffin",
|
||||
};
|
||||
|
||||
public static bool ShouldBeGhost(EntityAlive _entity)
|
||||
{
|
||||
if (_entity is EntityTrader)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityClass entityClass = EntityClass.list[_entity.entityClass];
|
||||
if (entityClass == null || entityClass.entityClassName == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (NotGhosted.Contains(entityClass.entityClassName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(entityClass.entityClassName);
|
||||
}
|
||||
|
||||
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
@@ -284,11 +339,11 @@ namespace NecromancerTome
|
||||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive entity = world.EntityAlives[i];
|
||||
if (!(entity is EntityTrader trader) || trader.IsDead())
|
||||
if (entity == null || entity.IsDead() || !ShouldBeGhost(entity))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (Ghosted.TryGetValue(trader.entityId, out GhostBody body))
|
||||
if (Ghosted.TryGetValue(entity.entityId, out GhostBody body))
|
||||
{
|
||||
if (IsIntact(body))
|
||||
{
|
||||
@@ -297,14 +352,14 @@ namespace NecromancerTome
|
||||
// His model was destroyed and rebuilt under him. Drop what is known about the
|
||||
// old one before building the new, or Converted and TintedMaterials keep
|
||||
// entries for renderers and materials that no longer exist.
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + trader.entityId +
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + entity.entityId +
|
||||
" came back with a new model - ghosting him again");
|
||||
Ghosted.Remove(trader.entityId);
|
||||
Ghosted.Remove(entity.entityId);
|
||||
Prune();
|
||||
}
|
||||
if (ApplyGreyscale(trader, out GhostBody fresh))
|
||||
if (ApplyGreyscale(entity, out GhostBody fresh))
|
||||
{
|
||||
Ghosted[trader.entityId] = fresh;
|
||||
Ghosted[entity.entityId] = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,7 +418,7 @@ namespace NecromancerTome
|
||||
/// leaves this trader unmarked and tries again on the next sweep. On true, _body carries
|
||||
/// the renderers taken over, which is how the next sweep tells this trader from a rebuilt
|
||||
/// one standing under the same entity id.</summary>
|
||||
public static bool ApplyGreyscale(EntityTrader _trader, out GhostBody _body)
|
||||
public static bool ApplyGreyscale(EntityAlive _trader, out GhostBody _body)
|
||||
{
|
||||
_body = default(GhostBody);
|
||||
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
|
||||
@@ -797,6 +852,21 @@ namespace NecromancerTome
|
||||
break;
|
||||
}
|
||||
|
||||
// НАЙДЕНО 2026-09-18, НО НЕ ИСПРАВЛЕНО - записано, чтобы не искать заново.
|
||||
// Пользователь: "собака не полупрозрачная". По логу у неё для этого всё было -
|
||||
// шейдер Standard с _Color, _SrcBlend, _DstBlend, "5 of 5 renderer(s) desaturated,
|
||||
// 5 opacity lever(s) installed". То есть альфа проставлена, а прозрачности нет.
|
||||
//
|
||||
// Разница с трейдерами, у которых она работает: их тело идёт по ДРУГОЙ ветке -
|
||||
// MakeBlendMaterial создаёт материал заново и ставит там _Mode = 2 (Fade). Здесь,
|
||||
// на ветке "блендим на месте", _Mode и _ZWrite не трогаются вовсе, а материал,
|
||||
// собранный как Opaque, приходит с _Mode = 0 и _ZWrite = 1. Юнити для перевода
|
||||
// Standard в прозрачность требует всю шестёрку: _Mode, _SrcBlend, _DstBlend,
|
||||
// _ZWrite, ключевые слова и очередь отрисовки - у нас выставлены четыре из шести.
|
||||
//
|
||||
// Собаку это чинить больше не нужно (она выведена из призраков совсем), но Медведь,
|
||||
// Волк и Гриф идут по этой же ветке. Если и они окажутся непрозрачными - причина
|
||||
// здесь, и лечится добавлением _Mode = 2 и _ZWrite = 0 рядом со строками ниже.
|
||||
if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend"))
|
||||
{
|
||||
_material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// КОМАНДА "АТАКУЙ ТО, НА ЧТО Я СМОТРЮ" - второй режим свитка Духа крысы, указание
|
||||
/// 2026-09-18: "у свитка призыва есть два режима (призыв и отзыв). Если крыса уже призвана,
|
||||
/// то призыв становится атакой. И крыса берёт целью атаки того, на кого указал прицел
|
||||
/// игрока. Если в прицеле нету целей для атаки, то пишется надпись "Нет цели для атаки"".
|
||||
///
|
||||
/// Это не Harmony-заплатка, а helper: точка входа одна и та же для всех питомцев -
|
||||
/// префикс на ItemActionSpawnEntity.Spawn в SummonPatch.cs. Второй патч на тот же метод
|
||||
/// только запутал бы порядок исполнения.
|
||||
///
|
||||
/// ОТКУДА БЕРЁТСЯ ЦЕЛЬ ПОД ПРИЦЕЛОМ. EntityPlayerLocal.HitInfo - публичное поле типа
|
||||
/// WorldRayHitInfo, которое PlayerMoveController перезаписывает КАЖДЫЙ КАДР результатом луча
|
||||
/// из прицела (Voxel.Raycast по GetLookRay, длина Utils.FastMax(cDigAndBuildDistance,
|
||||
/// cCollectItemDistance, 30f), то есть не меньше 30 метров). Ничего своего лучить не надо -
|
||||
/// игра уже посчитала это для интерфейса. Из попадания сущность достаётся собственным
|
||||
/// публичным методом игры ItemActionAttack.GetEntityFromHit(hitInfo), который внутри зовёт
|
||||
/// GameUtils.GetHitRootEntity(tag, transform) - то же самое, чем пользуется обычный удар
|
||||
/// оружием, а значит и попадание по конечности/голове разрешится в саму сущность.
|
||||
///
|
||||
/// Луч упирается в блоки - и это правильно: натравить крысу сквозь стену на то, чего игрок
|
||||
/// не видит, нельзя.
|
||||
///
|
||||
/// КОГО НЕЛЬЗЯ НАЗНАЧИТЬ ЦЕЛЬЮ (указание "пусть атакует всех существ кроме NPC торговцев"):
|
||||
/// - EntityTrader - прямо по указанию;
|
||||
/// - EntityPlayer - сам хозяин и вообще игроки: крыса не оружие против людей;
|
||||
/// - собственный хозяин и она сама - на всякий случай, отдельной проверкой;
|
||||
/// - мёртвые.
|
||||
/// Отсев сделан ЗДЕСЬ, а не в entityclasses.xml, потому что targetClasses у
|
||||
/// EAIApproachAndAttackTarget умеет только РАЗРЕШАТЬ тип, запретить им нельзя. В XML у крысы
|
||||
/// стоит class=EntityAlive - это разрешение задаче подхватить любую цель, которую ей дали;
|
||||
/// решение "кого дать" целиком принимается тут.
|
||||
/// </summary>
|
||||
public static class PetAttackCommand
|
||||
{
|
||||
/// <summary>Сколько тиков держится приказ. attackTargetTime уменьшается на 1 за тик ИИ и
|
||||
/// на нуле сбрасывает цель (EntityAlive: "if (attackTargetTime > 0) { attackTargetTime--;
|
||||
/// ... }"), так что это по сути "пока не убьёт". 6000 - заведомо больше любой драки;
|
||||
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
|
||||
public const int OrderTicks = 6000;
|
||||
|
||||
/// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не
|
||||
/// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary>
|
||||
public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet)
|
||||
{
|
||||
if (_owner == null || _pet == null || _pet.IsDead())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
EntityPlayerLocal local = _owner as EntityPlayerLocal;
|
||||
if (local == null)
|
||||
{
|
||||
// Не локальный игрок - HitInfo просто неоткуда взять (у мода нет сетевого слоя
|
||||
// нигде, см. SummonPatch.cs). Молча отказываем, как будто цели нет.
|
||||
Debug.Log("[NecromancerTome] PetAttackCommand: owner is not the local player, no crosshair to read");
|
||||
return false;
|
||||
}
|
||||
|
||||
WorldRayHitInfo hit = local.HitInfo;
|
||||
if (hit == null || !hit.bHitValid || hit.transform == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Entity hitEntity = ItemActionAttack.GetEntityFromHit(hit);
|
||||
EntityAlive target = hitEntity as EntityAlive;
|
||||
if (target == null || target.IsDead())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (target == _pet || target == _owner)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (target is EntityPlayer || target is EntityTrader)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Месть сбрасывается вместе с приказом: иначе задача SetAsTargetIfHurt могла бы на
|
||||
// следующем тике перебить наш приказ тем, кто ударил крысу последним.
|
||||
_pet.SetRevengeTarget(null);
|
||||
_pet.SetAttackTarget(target, OrderTicks);
|
||||
Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " +
|
||||
target.entityId + " (" + target.EntityClass.entityClassName + ")");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// СВЕТЯЩИЕСЯ ГЛАЗА у призванных зомбоживотных - указание 2026-09-18: "псу и волку сделай,
|
||||
/// глаза светлофиолетовые светящиеся".
|
||||
///
|
||||
/// ПОЧЕМУ ТОЛЬКО ДВОЕ, И ЭТО НЕ ВЫБОР, А ОГРАНИЧЕНИЕ МОДЕЛЕЙ. Покрасить глаза отдельно можно
|
||||
/// ровно тогда, когда они лежат ОТДЕЛЬНЫМ материалом. Проба шейдеров, которую печатает
|
||||
/// GhostTraderPatch, показала по питомцам такую картину:
|
||||
///
|
||||
/// necroZombieDog - 5 рендереров: FeralEye, NormalEye, LOD0..LOD2 - ГОДИТСЯ
|
||||
/// necroZombieWolf - 2 рендерера: DireWolf + DireWolfHair - НЕ ГОДИТСЯ
|
||||
/// necroZombieBear - 1 рендерер - НЕ ГОДИТСЯ
|
||||
/// necroZombieGriffin - 1 рендерер - НЕ ГОДИТСЯ
|
||||
///
|
||||
/// ПО ВОЛКУ ОТВЕТ ОКОНЧАТЕЛЬНЫЙ И ОТРИЦАТЕЛЬНЫЙ, по дампу 18.09:
|
||||
///
|
||||
/// DireWolf[0] shader='Game/Animal/Fur' texture=dire_wolf_df _Color=False
|
||||
/// DireWolfHair[0] shader='Game/SDCS/Hair' texture=Afro_normal _Color=False
|
||||
///
|
||||
/// Глазного рендерера у него НЕТ ВООБЩЕ - только шкура и шерсть, глаза запечены прямо в
|
||||
/// dire_wolf_df. Ни отдельного материала, ни даже _Color, чтобы хоть что-то подкрасить.
|
||||
/// Единственный оставшийся путь - править саму текстуру по пикселям, зная координаты глаз на
|
||||
/// развёртке. Поэтому LitEyes у Волка снят: пусть флаг не обещает того, чего нет.
|
||||
///
|
||||
/// У Медведя и Грифа материал один на всю модель, и отдельно глаза там не достать ничем,
|
||||
/// кроме правки самой текстуры по пикселям. Поэтому их в списке нет - см. PetInfo.LitEyes.
|
||||
///
|
||||
/// КРАСИТСЯ СВЕЧЕНИЕ, А НЕ ЦВЕТ. У зомби глаза светятся сами (_EmissionMap у этого материала
|
||||
/// уже назначена), и именно свечение читается как "горящие глаза". Поэтому ставится
|
||||
/// _EmissionColor, а _Color правится следом только чтобы радужка не спорила с ним при ярком
|
||||
/// дневном свете. Ключевое слово _EMISSION включается явно: материал, собранный без эмиссии,
|
||||
/// игнорирует _EmissionColor, пока оно выключено, - ровно тот же класс ловушки, что с
|
||||
/// _SrcBlend/_Mode у прозрачности (см. ApplyTransparency в GhostTraderPatch.cs).
|
||||
///
|
||||
/// ПОЧЕМУ ЭТО ТИК, А НЕ ХУК НА ПРИЗЫВ. Материалы появляются вместе с моделью, а она
|
||||
/// собирается ПОЗЖЕ создания сущности - это уже дважды стоило нам ошибок: сначала
|
||||
/// GhostTraderPatch пришлось перевести на опрос из-за трейдеров, потом
|
||||
/// TryIgnoreCollisionWithOwner из-за того, что коллайдеров в момент CreateEntity ещё нет.
|
||||
/// Здесь та же природа, поэтому сразу попытка с повтором: раз в секунду, до первого успеха,
|
||||
/// из общего тика в PetFollowPatch.
|
||||
/// </summary>
|
||||
public static class PetEyeGlow
|
||||
{
|
||||
/// <summary>Фиолетовый, НАСЫЩЕННЫЙ. Первая версия была светлой (0.72, 0.55, 1.0) и в игре
|
||||
/// дала ровно то, о чём сказал пользователь: "у собаки глаза просто белые".
|
||||
///
|
||||
/// Причина арифметическая: _EmissionColor читается как HDR, цвет умножается на яркость.
|
||||
/// При множителе 6 светлый фиолетовый превращался в (4.3, 3.3, 6.0) - все три канала
|
||||
/// далеко за единицей, и тонемаппинг сводил их в белое пятно. Оттенок выживает только
|
||||
/// когда каналы РАСХОДЯТСЯ: отсюда низкий зелёный и умеренная яркость ниже.</summary>
|
||||
public static readonly Color EyeColour = new Color(0.55f, 0.18f, 1f);
|
||||
|
||||
/// <summary>ПОДНЯТО С 2.4 ДО 6 после первой проверки: "у собаки они вообще никак не
|
||||
/// светятся". Цвет сменился (это видно), а свечения нет - значит либо _EmissionColor у
|
||||
/// этого шейдера читается как HDR и 2.4 для него ничто, либо свечение вообще идёт не
|
||||
/// отсюда. Шесть - это попытка проверить первое предположение, а чтобы проверить второе,
|
||||
/// рядом добавлен разовый дамп всех материалов питомца (DumpMaterialsOnce).</summary>
|
||||
/// <summary>1.6, а не 6: см. комментарий к EyeColour - на шести глаза выгорали в белый.
|
||||
/// Итоговое свечение (0.88, 0.29, 1.6): синий вдвое выше красного и вшестеро выше
|
||||
/// зелёного, так что фиолетовый читается, а не тонет в пересвете.</summary>
|
||||
public const float EmissionIntensity = 1.6f;
|
||||
|
||||
/// <summary>Классы, по которым дамп уже печатался: он нужен один раз на вид, а не на
|
||||
/// каждого призванного.</summary>
|
||||
public static readonly System.Collections.Generic.HashSet<string> Dumped =
|
||||
new System.Collections.Generic.HashSet<string>();
|
||||
|
||||
/// <summary>ДИАГНОСТИКА, добавлена 2026-09-18. Первая попытка зажечь глаза дала половину
|
||||
/// результата: у Пса цвет сменился, но свечения нет, а у Волка не изменилось ничего -
|
||||
/// значит материала с "eye" в имени текстуры у него просто не нашлось. Гадать дальше
|
||||
/// дороже, чем один раз посмотреть: этот дамп печатает по каждому виду все рендереры, их
|
||||
/// материалы, шейдеры, имена текстур и наличие свойств свечения. По нему и будет видно,
|
||||
/// за что хвататься - и надо ли вообще.</summary>
|
||||
public static void DumpMaterialsOnce(Entity _pet, string _className)
|
||||
{
|
||||
if (_className == null || !Dumped.Add(_className))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Renderer[] renderers = _pet.GetComponentsInChildren<Renderer>(true);
|
||||
Debug.Log("[NecromancerTome] PetEyeGlow: ДАМП материалов " + _className + " - " +
|
||||
(renderers != null ? renderers.Length : 0) + " рендерер(ов)");
|
||||
if (renderers == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (Renderer renderer in renderers)
|
||||
{
|
||||
if (renderer == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Material[] mats = renderer.sharedMaterials;
|
||||
for (int i = 0; mats != null && i < mats.Length; i++)
|
||||
{
|
||||
Material m = mats[i];
|
||||
if (m == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string albedo = GhostTraderPatch.FindAlbedoProperty(m);
|
||||
Texture tex = albedo != null ? m.GetTexture(albedo) : null;
|
||||
Debug.Log("[NecromancerTome] PetEyeGlow: " + renderer.name + "[" + i + "] shader='" +
|
||||
(m.shader != null ? m.shader.name : "null") + "' albedo=" + (albedo ?? "-") +
|
||||
" texture=" + (tex != null ? tex.name : "-") +
|
||||
" _EmissionColor=" + m.HasProperty("_EmissionColor") +
|
||||
" _EmissionMap=" + (m.HasProperty("_EmissionMap") && m.GetTexture("_EmissionMap") != null) +
|
||||
" _EmissionIntensity=" + m.HasProperty("_EmissionIntensity") +
|
||||
" _Color=" + m.HasProperty("_Color"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ПЕРЕПИСАНО 2026-09-18 ПО ДАМПУ. Первая версия искала материал по имени
|
||||
/// ТЕКСТУРЫ ("eye"), нашла у собаки ровно один и зажгла его - а глаза всё равно не
|
||||
/// светились. Дамп показал, почему:
|
||||
///
|
||||
/// FeralEye[0] shader='Game/Autodesk' texture=feral_eye _EmissionMap=True
|
||||
/// NormalEye[0] shader='Standard' texture=HD_ZombieDog _EmissionMap=False
|
||||
/// LOD0..LOD2 shader='Standard' texture=HD_ZombieDog
|
||||
///
|
||||
/// Глаз у собаки ДВА РАЗНЫХ, отдельными рендерерами. Светящийся feral - тот, что горит у
|
||||
/// разъярённой собаки, и в обычном состоянии он не показывается; именно его мы и зажгли,
|
||||
/// не увидев ничего. А обычный глаз - NormalEye - берёт текстуру ТУШКИ (HD_ZombieDog),
|
||||
/// поэтому поиск по имени текстуры его пропускал.
|
||||
///
|
||||
/// Поэтому теперь глаз опознаётся по имени РЕНДЕРЕРА, а имя текстуры оставлено как
|
||||
/// запасной признак. Материал у NormalEye свой собственный, так что правка цвета и
|
||||
/// свечения на нём затрагивает только глаз, хотя текстура и общая с телом.</summary>
|
||||
public static bool IsEyeRenderer(Renderer _renderer)
|
||||
{
|
||||
if (_renderer == null || _renderer is ParticleSystemRenderer)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (_renderer.name != null && _renderer.name.ToLower().Contains("eye"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Material[] shared = _renderer.sharedMaterials;
|
||||
if (shared == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (Material m in shared)
|
||||
{
|
||||
if (IsEyeMaterial(m))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Запасной признак: текстура с "eye" в имени (у собаки это feral_eye).</summary>
|
||||
public static bool IsEyeMaterial(Material _material)
|
||||
{
|
||||
if (_material == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string albedo = GhostTraderPatch.FindAlbedoProperty(_material);
|
||||
if (albedo == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Texture texture = _material.GetTexture(albedo);
|
||||
return texture != null && texture.name != null &&
|
||||
texture.name.ToLower().Contains("eye");
|
||||
}
|
||||
|
||||
/// <summary>true, только если глаза действительно нашлись и были подожжены. Пока false,
|
||||
/// тик повторяет попытку - модель может быть ещё не собрана.</summary>
|
||||
public static bool TryLightEyes(Entity _pet)
|
||||
{
|
||||
if (_pet == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Renderer[] renderers = _pet.GetComponentsInChildren<Renderer>(true);
|
||||
if (renderers == null || renderers.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int lit = 0;
|
||||
foreach (Renderer renderer in renderers)
|
||||
{
|
||||
if (!IsEyeRenderer(renderer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Рендерер целиком глазной, поэтому красим ВСЕ его материалы: мешу глаза больше
|
||||
// ничего не принадлежит, а вот у собаки их два (обычный и feral), и попасть надо
|
||||
// в оба - какой из них покажется, решает сама игра.
|
||||
Material[] instances = renderer.materials;
|
||||
foreach (Material eye in instances)
|
||||
{
|
||||
if (eye == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (eye.HasProperty("_EmissionColor"))
|
||||
{
|
||||
eye.EnableKeyword("_EMISSION");
|
||||
eye.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
||||
eye.SetColor("_EmissionColor", EyeColour * EmissionIntensity);
|
||||
if (eye.HasProperty("_EmissionIntensity"))
|
||||
{
|
||||
eye.SetFloat("_EmissionIntensity", EmissionIntensity);
|
||||
}
|
||||
lit++;
|
||||
}
|
||||
if (eye.HasProperty("_Color"))
|
||||
{
|
||||
Color tint = eye.GetColor("_Color");
|
||||
eye.SetColor("_Color", new Color(EyeColour.r, EyeColour.g, EyeColour.b, tint.a));
|
||||
}
|
||||
}
|
||||
Debug.Log("[NecromancerTome] PetEyeGlow: рендерер глаз '" + renderer.name + "', материалов " +
|
||||
instances.Length);
|
||||
}
|
||||
|
||||
if (lit > 0)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] PetEyeGlow: pet " + _pet.entityId + " - " + lit +
|
||||
" eye material(s) lit");
|
||||
}
|
||||
return lit > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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 < 0f -> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// УБИЙСТВА ПИТОМЦЕМ ТЕПЕРЬ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ - 2026-09-18.
|
||||
///
|
||||
/// ПОЧЕМУ РАНЬШЕ НЕ ЗАСЧИТЫВАЛИСЬ. Ваниль решает, чей это фраг, ровно в одном месте:
|
||||
///
|
||||
/// public void AwardKillXPServer(DamageSource _source, EntityAlive _killingEntity)
|
||||
/// {
|
||||
/// if (_source == null || _source.BuffClass != null) return; // (2)
|
||||
/// EntityPlayer entityPlayer = _killingEntity as EntityPlayer; // (1)
|
||||
/// if ((bool)entityPlayer && !(entityPlayer == this) && ...)
|
||||
/// entityPlayer.AddKillXP(this, _source.AttackingItem, num);
|
||||
/// }
|
||||
///
|
||||
/// (1) Убийца обязан БЫТЬ игроком. При укусе питомца _killingEntity - это сам питомец
|
||||
/// (берётся как world.GetEntity(_dmResponse.Source.getEntityId())), приведение к
|
||||
/// EntityPlayer даёт null, и AddKillXP не вызывается вовсе. А счётчик Некромантии висит
|
||||
/// постфиксом именно на AddKillXP (NecromancyKillCreditPatch.cs) - значит не растут ни
|
||||
/// уровень, ни урон Ножа некроманта, который от этого счётчика и считается.
|
||||
///
|
||||
/// ЧТО ДЕЛАЕТ ЭТОТ ПРЕФИКС. Подменяет _killingEntity на владельца, если убил его питомец.
|
||||
/// Дальше всё едет по ванильному пути само: и опыт, и наш счётчик, и урон Ножа, и испытания -
|
||||
/// потому что мод по-прежнему СЛЕДУЕТ решению ванили, а не дублирует его. Это то же правило,
|
||||
/// ради которого счёт убийств 16.09 переехал из XML в постфикс на AddKillXP.
|
||||
///
|
||||
/// ПОЧЕМУ ЭТО ВООБЩЕ ПОТРЕБОВАЛОСЬ ОБСУЖДАТЬ. 17.09 пользователь решил обратное для зомби под
|
||||
/// Камнем духов: "если зомби под контролем камня духов убивает другого, то в скилл это не
|
||||
/// идёт, что в целом логично. Пока оставляем так". Подчинённые зомби в TrackedPets не лежат,
|
||||
/// поэтому то решение остаётся в силе - здесь меняется только судьба ПРИЗВАННЫХ питомцев,
|
||||
/// которых игрок крафтит, кормит и водит за собой.
|
||||
///
|
||||
/// ВТОРАЯ ПОЛОВИНА: ДОБИВАНИЕ КРОВОТЕЧЕНИЕМ. Строка (2) отсекает любой урон от баффа ДО
|
||||
/// всякой проверки убийцы - то есть зомби, истёкший кровью, не даёт опыта никому и никогда,
|
||||
/// даже если рану нанёс сам игрок. Для Духа крысы это половина всего урона (buffNecroRatBleed
|
||||
/// капает 5 в секунду при укусе в 5), и без этой части починка была бы половинчатой.
|
||||
///
|
||||
/// Мешало одно: в DamageSource от баффа НЕТ идентификатора того, кто бафф наложил. Значит
|
||||
/// помнить приходится самим - что и делает RecentPetBites ниже: каждый удар нашего питомца
|
||||
/// записывает "эту жертву кусал питомец такого-то" со сроком годности чуть больше, чем живёт
|
||||
/// кровотечение. Когда жертва умирает от баффа, запись и отвечает, кому зачесть.
|
||||
///
|
||||
/// Ванильный путь при этом НЕ ПРАВИТСЯ: мы не обнуляем _source.BuffClass (это общий объект,
|
||||
/// его читают и дальше - PartyShareKillServer, разбор смерти игрока), а сами зовём
|
||||
/// AddKillXP и отменяем оригинал, который всё равно вышел бы ни с чем.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "AwardKillXPServer")]
|
||||
public static class Patch_EntityAlive_AwardKillXPServer_PetCredit
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, DamageSource _source, ref EntityAlive _killingEntity)
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Смерть от баффа: ваниль выйдет по строке (2) ни с чем, поэтому зачисляем сами.
|
||||
if (_source.BuffClass != null)
|
||||
{
|
||||
EntityPlayer bleedOwner = RecentPetBites.OwnerOf(__instance.entityId);
|
||||
if (bleedOwner == null || bleedOwner == __instance || !EntityClass.list.ContainsKey(__instance.entityClass))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// KillXPScale у баффа обычно нулевой - это множитель "сколько опыта дать", и на
|
||||
// нуле AddKillXP не дал бы ничего. Единица = обычное убийство.
|
||||
float scale = _source.KillXPScale > 0f ? _source.KillXPScale : 1f;
|
||||
bleedOwner.AddKillXP(__instance, _source.AttackingItem, scale);
|
||||
RecentPetBites.Forget(__instance.entityId);
|
||||
Debug.Log("[NecromancerTome] PetKillCredit: bleed kill of " + __instance.entityId +
|
||||
" credited to owner " + bleedOwner.entityId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_killingEntity == null || _killingEntity is EntityPlayer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityPlayer owner = PetFollowPatch.FindOwnerOfPet(_killingEntity.entityId);
|
||||
if (owner == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Debug.Log("[NecromancerTome] PetKillCredit: kill by pet " + _killingEntity.entityId +
|
||||
" credited to owner " + owner.entityId);
|
||||
_killingEntity = owner;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Кто кого кусал из питомцев и когда. Запись живёт чуть дольше кровотечения
|
||||
/// (buffNecroRatBleed - 15 секунд), чтобы добивание точно попало в окно, и при этом не
|
||||
/// превращалась в вечную память: зомби, укушенный минуту назад и умерший от чужой ловушки,
|
||||
/// не должен приносить очки владельцу питомца.</summary>
|
||||
public static class RecentPetBites
|
||||
{
|
||||
public const float MemorySeconds = 18f;
|
||||
|
||||
public struct Bite
|
||||
{
|
||||
public int OwnerEntityId;
|
||||
public float ExpiresAt;
|
||||
}
|
||||
|
||||
public static readonly Dictionary<int, Bite> Bites = new Dictionary<int, Bite>();
|
||||
|
||||
public static void Remember(int _victimEntityId, int _ownerEntityId)
|
||||
{
|
||||
Bites[_victimEntityId] = new Bite
|
||||
{
|
||||
OwnerEntityId = _ownerEntityId,
|
||||
ExpiresAt = Time.time + MemorySeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public static void Forget(int _victimEntityId)
|
||||
{
|
||||
Bites.Remove(_victimEntityId);
|
||||
}
|
||||
|
||||
public static EntityPlayer OwnerOf(int _victimEntityId)
|
||||
{
|
||||
if (!Bites.TryGetValue(_victimEntityId, out Bite bite))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (Time.time > bite.ExpiresAt)
|
||||
{
|
||||
Bites.Remove(_victimEntityId);
|
||||
return null;
|
||||
}
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
return world != null ? world.GetEntity(bite.OwnerEntityId) as EntityPlayer : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Здесь и запоминается укус. Точка выбрана самая общая: любой урон, нанесённый
|
||||
/// нашим питомцем, метит жертву - неважно, укус это, кровотечение от прошлого укуса или
|
||||
/// что-то ещё, что мы добавим потом.
|
||||
///
|
||||
/// Постфикс, а не префикс: смысл только в том, чтобы записать факт, а вмешиваться в сам урон
|
||||
/// незачем. Заодно здесь же подчищается таблица - редко и дёшево, без своего таймера.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
|
||||
public static class Patch_EntityAlive_DamageEntity_RememberPetBite
|
||||
{
|
||||
public static int callsSincePrune;
|
||||
|
||||
public static void Postfix(EntityAlive __instance, DamageSource _damageSource)
|
||||
{
|
||||
if (_damageSource == null || __instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int attackerId = _damageSource.getEntityId();
|
||||
if (attackerId == -1 || attackerId == __instance.entityId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EntityPlayer owner = PetFollowPatch.FindOwnerOfPet(attackerId);
|
||||
if (owner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
RecentPetBites.Remember(__instance.entityId, owner.entityId);
|
||||
|
||||
if (++callsSincePrune >= 200)
|
||||
{
|
||||
callsSincePrune = 0;
|
||||
Prune();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Prune()
|
||||
{
|
||||
if (RecentPetBites.Bites.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<int> stale = new List<int>();
|
||||
foreach (KeyValuePair<int, RecentPetBites.Bite> entry in RecentPetBites.Bites)
|
||||
{
|
||||
if (Time.time > entry.Value.ExpiresAt)
|
||||
{
|
||||
stale.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
foreach (int id in stale)
|
||||
{
|
||||
RecentPetBites.Bites.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+349
-56
@@ -85,6 +85,54 @@ namespace NecromancerTome
|
||||
/// fix the earlier "dog died, no way to get the book back" complaint: with nothing
|
||||
/// ever taken, there's nothing to lose when the dog dies off-screen.</summary>
|
||||
public bool ConsumesBook;
|
||||
|
||||
/// <summary>Указание 2026-09-18, пока только для Духа крысы: "если крыса уже
|
||||
/// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом
|
||||
/// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было.
|
||||
/// Разбор прицела - в PetCommandPatch.cs.</summary>
|
||||
public bool CommandsAttack;
|
||||
|
||||
/// <summary>Что показать, когда команда атаки отдана, но под прицелом некого
|
||||
/// атаковать. Читается только при CommandsAttack.</summary>
|
||||
public string NoTargetKey;
|
||||
|
||||
/// <summary>Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено.
|
||||
///
|
||||
/// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок.
|
||||
/// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок
|
||||
/// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у
|
||||
/// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет
|
||||
/// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в
|
||||
/// State.Home и летит обратно СВОИМ полётом:
|
||||
///
|
||||
/// if (state != State.Home && --homeCheckDelay <= 0) {
|
||||
/// homeCheckDelay = 60;
|
||||
/// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop);
|
||||
/// }
|
||||
/// ... StartHome(getHomePosition().position.ToVector3());
|
||||
///
|
||||
/// Достаточно каждую секунду переставлять этот дом на позицию хозяина
|
||||
/// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без
|
||||
/// единого телепорта и без нашего пафайндинга.</summary>
|
||||
public int FlyingHomeRadius;
|
||||
|
||||
/// <summary>Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и
|
||||
/// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в
|
||||
/// текстуру шкуры, доставать их нечем.</summary>
|
||||
public bool LitEyes;
|
||||
|
||||
/// <summary>За сколько блоков от хозяина держится этот питомец. Ноль означает "как
|
||||
/// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку
|
||||
/// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус
|
||||
/// больше, чтобы туша не наступала хозяину на пятки.</summary>
|
||||
public float FollowDistance;
|
||||
|
||||
/// <summary>Вешать ли этому питомцу собственную задачу следования
|
||||
/// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки,
|
||||
/// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для
|
||||
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
|
||||
/// разные стороны.</summary>
|
||||
public bool UsesFollowTask;
|
||||
}
|
||||
|
||||
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
|
||||
@@ -98,6 +146,14 @@ namespace NecromancerTome
|
||||
NothingToRecallKey = "necroZombieDogNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieDog",
|
||||
ConsumesBook = false,
|
||||
LitEyes = true,
|
||||
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,6 +187,18 @@ namespace NecromancerTome
|
||||
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieGriffin",
|
||||
ConsumesBook = false,
|
||||
// 15 блоков: заметно меньше 32-метрового поводка, чтобы тот вообще не
|
||||
// понадобился, и достаточно, чтобы Гриф не висел у игрока над головой.
|
||||
FlyingHomeRadius = 15,
|
||||
// UsesFollowTask у Грифа НЕТ с 18.09: он снова летающий (EntityVulture), а
|
||||
// задача следования наземная - она гоняла бы его пафайндером по земле и
|
||||
// дралась бы с его собственным полётом. Приказ атаковать оставлен.
|
||||
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -142,6 +210,14 @@ namespace NecromancerTome
|
||||
NothingToRecallKey = "necroZombieBearNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieBear",
|
||||
ConsumesBook = false,
|
||||
FollowDistance = 3f,
|
||||
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -153,6 +229,34 @@ namespace NecromancerTome
|
||||
NothingToRecallKey = "necroZombieWolfNothingToRecall",
|
||||
SummonItemName = "bookSummonZombieWolf",
|
||||
ConsumesBook = false,
|
||||
FollowDistance = 3f,
|
||||
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
|
||||
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
|
||||
// цель под прицелом, своя задача следования вместо телепорт-поводка.
|
||||
// AlreadyActiveKey у всех четверых теперь не читается никогда.
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
// "Дух крысы", указание 2026-09-18 - питомец начального уровня (Некромантия 3) и
|
||||
// первый, у кого поведение задано намеренно, а не унаследовано от зомбопса. На нём
|
||||
// отлаживается ИИ питомцев вообще, поэтому он единственный, у кого стоят оба новых
|
||||
// флага. AlreadyActiveKey у него НЕ ЧИТАЕТСЯ НИКОГДА: повторный Action0 - это не
|
||||
// отказ "уже призван", а команда атаковать (CommandsAttack), так что вместо него
|
||||
// показывается либо ничего (приказ принят), либо NoTargetKey.
|
||||
{
|
||||
"necroRatSpirit",
|
||||
new PetInfo
|
||||
{
|
||||
AlreadyActiveKey = null,
|
||||
RecalledKey = "necroRatSpiritRecalled",
|
||||
NothingToRecallKey = "necroRatSpiritNothingToRecall",
|
||||
SummonItemName = "bookSummonRatSpirit",
|
||||
ConsumesBook = false,
|
||||
CommandsAttack = true,
|
||||
NoTargetKey = "necroPetNoTarget",
|
||||
UsesFollowTask = true,
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -161,8 +265,17 @@ namespace NecromancerTome
|
||||
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.</summary>
|
||||
public const int RecallActionIndex = 1;
|
||||
|
||||
/// <summary>Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn.
|
||||
/// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут
|
||||
/// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат
|
||||
/// false из префикса его не отменяет), а делать им надо противоположное - в одном случае
|
||||
/// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что
|
||||
/// поставил игрок.</summary>
|
||||
public static bool summonAllowedThisCall;
|
||||
|
||||
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
|
||||
{
|
||||
summonAllowedThisCall = false;
|
||||
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
|
||||
{
|
||||
return true;
|
||||
@@ -207,15 +320,73 @@ namespace NecromancerTome
|
||||
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
// Action0 при уже живом питомце. Для всех, кроме Духа крысы, это тупик "уже
|
||||
// призван". Для него - команда атаковать то, на что смотрит игрок: см.
|
||||
// PetCommandPatch.cs, там же и весь отсев (торговцы, игроки, мёртвые).
|
||||
if (tooltips.CommandsAttack)
|
||||
{
|
||||
EntityAlive pet = holdingEntity.world != null
|
||||
? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive
|
||||
: null;
|
||||
if (!PetAttackCommand.TryOrderAttack(holdingEntity, pet) && holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NoTargetKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (holdingEntity.world != null)
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
summonAllowedThisCall = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
|
||||
/// Последней строкой ваниль делает вот что:
|
||||
///
|
||||
/// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600);
|
||||
///
|
||||
/// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и
|
||||
/// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не
|
||||
/// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа.
|
||||
///
|
||||
/// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает
|
||||
/// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего.
|
||||
///
|
||||
/// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после
|
||||
/// команды атаковать - то есть приказ гасил бы сам себя.</summary>
|
||||
public static void Postfix(ItemActionSpawnEntity __instance)
|
||||
{
|
||||
if (!summonAllowedThisCall)
|
||||
{
|
||||
return;
|
||||
}
|
||||
summonAllowedThisCall = false;
|
||||
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo petInfo) || !petInfo.UsesFollowTask)
|
||||
{
|
||||
return;
|
||||
}
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
if (world == null || world.EntityAlives == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||
for (int i = world.EntityAlives.Count - 1; i >= 0; i--)
|
||||
{
|
||||
EntityAlive candidate = world.EntityAlives[i];
|
||||
if (candidate != null && candidate.entityClass == petClassId && candidate.GetAttackTarget() != null)
|
||||
{
|
||||
candidate.SetAttackTarget(null, 0);
|
||||
Debug.Log("[NecromancerTome] SummonPatch: cleared the target vanilla Spawn handed to fresh pet " +
|
||||
candidate.entityId + " (this species never attacks on its own)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>summonItemName is null when this species doesn't consume its book on summon
|
||||
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
|
||||
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
|
||||
@@ -267,6 +438,140 @@ namespace NecromancerTome
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 2026-09-18. Первая
|
||||
/// (Patch_..._IsActionRunning_FreeTheRecall ниже) сняла блокировку вторичного действия
|
||||
/// основным, и этого оказалось мало: "отозвать собаку получилось далеко не с первого раза.
|
||||
/// Это проблема всех питомцев".
|
||||
///
|
||||
/// ОСТАВШАЯСЯ ПРИЧИНА - ТА ЖЕ САМАЯ СЕМЬЯ, что и у призыва, только в другом слоте. Отзыв идёт
|
||||
/// через ItemActionSpawnEntity.Spawn, а до него надо ДОЖИТЬ через автомат состояний:
|
||||
///
|
||||
/// OnHoldingUpdate: stateTime += 0.05
|
||||
/// Anim: если stateTime >= animWait -> state = Spawn
|
||||
/// Spawn: Spawn(data); state = End
|
||||
/// ExecuteAction(_bReleased: true): state = None // отпустил - всё сбросилось
|
||||
///
|
||||
/// При animWait = 0.1 это ТРИ тика: 0.05 (мало), 0.10 (переход в Spawn), и только третий
|
||||
/// вызывает сам Spawn. То есть кнопку надо продержать около 0.15 секунды. У призыва это
|
||||
/// лечилось уменьшением animWait до 0.05, но там и оставалось два тика; меньше двух автомат
|
||||
/// не умеет в принципе - один переход и одно исполнение.
|
||||
///
|
||||
/// Поэтому отзыв вынут из автомата совсем: он делается прямо на НАЖАТИИ, в префиксе
|
||||
/// ExecuteAction, и оригинал не запускается вовсе. Никакого удержания, никакого ожидания.
|
||||
///
|
||||
/// ЗАЩЁЛКА ОБЯЗАТЕЛЬНА. ExecuteAction(_bReleased: false) зовётся не один раз за клик, а
|
||||
/// КАЖДЫЙ КАДР, пока кнопка нажата (PlayerMoveController проверяет Secondary.IsPressed, а не
|
||||
/// WasPressed), и вся защита от повторов у ванили держится на её же state == None, который мы
|
||||
/// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и
|
||||
/// следом залило экран надписью "не призван" по разу в кадр.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")]
|
||||
public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall
|
||||
{
|
||||
/// <summary>Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого
|
||||
/// клика и заведомо меньше осмысленного повторного нажатия.</summary>
|
||||
public const float RepeatGuardSeconds = 0.5f;
|
||||
|
||||
public static float lastRecallTime;
|
||||
|
||||
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData, bool _bReleased)
|
||||
{
|
||||
if (_bReleased || _actionData == null ||
|
||||
_actionData.indexInEntityOfAction != Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallActionIndex)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(
|
||||
__instance.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
EntityAlive holdingEntity = _actionData.invData != null ? _actionData.invData.holdingEntity : null;
|
||||
if (holdingEntity == null || holdingEntity.world == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Time.time - lastRecallTime < RepeatGuardSeconds)
|
||||
{
|
||||
// Кнопка всё ещё зажата с прошлого кадра - молча проглатываем.
|
||||
return false;
|
||||
}
|
||||
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
|
||||
if (petClassId == -1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
lastRecallTime = Time.time;
|
||||
|
||||
List<OwnedEntityData> owned = holdingEntity.GetOwnedEntities(petClassId);
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id,
|
||||
petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null);
|
||||
holdingEntity.PlayOneShot(__instance.soundWarn);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку
|
||||
/// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза".
|
||||
///
|
||||
/// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает,
|
||||
/// пускать ли вторичное действие, так:
|
||||
///
|
||||
/// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions();
|
||||
/// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт"
|
||||
/// ...
|
||||
/// if (flag12 && flag15 && (flag16 || !flag18)) // flag15 = Secondary.IsPressed
|
||||
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
|
||||
///
|
||||
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
|
||||
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
|
||||
///
|
||||
/// public override bool IsActionRunning(ItemActionData _actionData)
|
||||
/// => ((ItemActionDataSpawnEntity)_actionData).state != State.None;
|
||||
///
|
||||
/// Состояние проходит None -> Anim -> Spawn -> **End**, и в None возвращается ТОЛЬКО из
|
||||
/// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже
|
||||
/// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок
|
||||
/// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0
|
||||
/// висит именно в End, - и первый отзыв уходит в никуда.
|
||||
///
|
||||
/// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт".
|
||||
/// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует
|
||||
/// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл
|
||||
/// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только
|
||||
/// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт
|
||||
/// основного по-прежнему заперт настоящим полем state, которое так и осталось End.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ItemActionSpawnEntity), "IsActionRunning")]
|
||||
public static class Patch_ItemActionSpawnEntity_IsActionRunning_FreeTheRecall
|
||||
{
|
||||
public static void Postfix(ItemActionSpawnEntity __instance, ItemActionData _actionData, ref bool __result)
|
||||
{
|
||||
if (!__result || _actionData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(__instance.entityToSpawn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_actionData is ItemActionSpawnEntity.ItemActionDataSpawnEntity data
|
||||
&& data.state == ItemActionSpawnEntity.ItemActionDataSpawnEntity.State.End)
|
||||
{
|
||||
__result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
|
||||
public static class Patch_EntityFactory_CreateEntity_PetOwnership
|
||||
{
|
||||
@@ -323,69 +628,57 @@ namespace NecromancerTome
|
||||
{
|
||||
owner.inventory.DecHoldingItem(1);
|
||||
}
|
||||
PetFollowPatch.Register(owner, __result);
|
||||
IgnoreCollisionWithOwner(owner, __result);
|
||||
ApplyGhostlyTransparency(__result);
|
||||
// Первая попытка развести коллайдеры - здесь же, чтобы в удачном случае не ждать
|
||||
// секунду до тика. Удачной она, скорее всего, НЕ БУДЕТ: модель питомца на этом шаге
|
||||
// ещё не собрана и коллайдеров у него нет (разбор - в TryIgnoreCollisionWithOwner).
|
||||
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
|
||||
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
|
||||
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
|
||||
petInfo.FlyingHomeRadius);
|
||||
AttachFollowTask(owner, __result, petInfo);
|
||||
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
|
||||
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
|
||||
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
|
||||
// вид всем питомцам теперь даёт GhostTraderPatch.cs - та же система, что у торговцев,
|
||||
// с клонированием материала, обесцвечиванием и подбором blend-шейдера.
|
||||
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
|
||||
}
|
||||
|
||||
/// <summary>User request 2026-08-28 ("нематериальными") - makes the pet pass through its
|
||||
/// own owner specifically, via Physics.IgnoreCollision on every collider pair between the
|
||||
/// two, rather than stripping the pet's PhysicsBody entirely the way vanilla's own
|
||||
/// animalInsectSwarm does for its "no physics body at all" look (confirmed by checking
|
||||
/// entityclasses.xml). That works for a flying swarm; a ground-walking dog with no
|
||||
/// collider at all would fall through the terrain. This keeps it solid against ground and
|
||||
/// zombies - just not its owner - which also directly closes the last piece of the
|
||||
/// spawn-launch bug documented in items.xml/PetFollowPatch.cs (nothing left to shove the
|
||||
/// player if the two colliders can't touch in the first place).</summary>
|
||||
public static void IgnoreCollisionWithOwner(EntityPlayer owner, Entity pet)
|
||||
{
|
||||
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>();
|
||||
Collider[] petColliders = pet.GetComponentsInChildren<Collider>();
|
||||
foreach (Collider oc in ownerColliders)
|
||||
{
|
||||
if (oc == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (Collider pc in petColliders)
|
||||
{
|
||||
if (pc == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Physics.IgnoreCollision(oc, pc, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>User request 2026-08-28 ("слегка прозрачными") - best effort only. Directly
|
||||
/// sets renderer.material.color's alpha, the same technique ItemActionSpawnTurret uses for
|
||||
/// its own placement-preview tint (confirmed by decompiling it), but that only visibly
|
||||
/// shows up if the model's actual shader supports alpha blending - most opaque mob
|
||||
/// shaders in this game don't, and there's no reliable XML/reflection-only way to swap a
|
||||
/// live renderer's shader to a transparent variant without risking breaking how it's lit.
|
||||
/// <summary>Вешает питомцу собственную задачу следования - указание 2026-09-18, пока
|
||||
/// только Духу крысы (PetInfo.UsesFollowTask).
|
||||
///
|
||||
/// BUG FIXED 2026-08-28: the Insect Swarm's renderers use a particle shader
|
||||
/// ("Game Particles/surfaceShader_masked_particleEnhanced") that has no "_Color" property
|
||||
/// at all - setting .color on it doesn't throw, but Unity logs "doesn't have a color
|
||||
/// property '_Color'" on every single access, once per renderer per spawn (confirmed in
|
||||
/// output_log - this is what the user saw as "an error about colors"). HasProperty check
|
||||
/// added so this silently skips any renderer whose shader doesn't support it instead of
|
||||
/// spamming the log - the visual effect was never going to work on those anyway.</summary>
|
||||
public static void ApplyGhostlyTransparency(Entity pet)
|
||||
/// ЧЕРЕЗ КОД, А НЕ ЧЕРЕЗ XML - см. шапку PetFollowTask.cs: XML-путь существует, но упирается
|
||||
/// в Type.GetType с именем сборки, а этот не упирается ни во что и заодно позволяет отдать
|
||||
/// владельца прямо в поле задачи, без поиска по ownedEntities на каждом тике.
|
||||
///
|
||||
/// Приоритет 3: у крысы в entityclasses.xml объявлены AITask-1 (ApproachAndAttackTarget) и
|
||||
/// AITask-2 (Look), а AITask-3 пустой - то есть следование встаёт ровно туда, где в XML
|
||||
/// кончился список, и ниже погони за целью. Разводить их приоритетом при этом всё равно
|
||||
/// недостаточно: задача следования сама отказывается работать, пока у питомца есть цель
|
||||
/// (NecroFollowOwnerTask.CanExecute), и несёт те же MutexBits=3, что ванильный
|
||||
/// EAIApproachSpot.</summary>
|
||||
public static void AttachFollowTask(EntityPlayer owner, Entity pet, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo)
|
||||
{
|
||||
Renderer[] renderers = pet.GetComponentsInChildren<Renderer>();
|
||||
foreach (Renderer renderer in renderers)
|
||||
if (!petInfo.UsesFollowTask)
|
||||
{
|
||||
if (renderer == null || renderer.material == null || !renderer.material.HasProperty("_Color"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Color color = renderer.material.color;
|
||||
color.a = 0.55f;
|
||||
renderer.material.color = color;
|
||||
return;
|
||||
}
|
||||
EntityAlive alive = pet as EntityAlive;
|
||||
if (alive == null || alive.aiManager == null || alive.aiManager.tasks == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SummonPatch: pet " + pet.entityId +
|
||||
" has no aiManager - follow task NOT attached");
|
||||
return;
|
||||
}
|
||||
NecroFollowOwnerTask task = new NecroFollowOwnerTask { OwnerEntityId = owner.entityId };
|
||||
if (petInfo.FollowDistance > 0f)
|
||||
{
|
||||
task.SlotDistance = petInfo.FollowDistance;
|
||||
}
|
||||
task.Init(alive);
|
||||
alive.aiManager.tasks.AddTask(3, task);
|
||||
Debug.Log("[NecromancerTome] SummonPatch: follow task attached to pet " + pet.entityId +
|
||||
" (owner " + owner.entityId + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,16 @@ namespace NecromancerTome
|
||||
public static readonly Dictionary<string, VultureBasedPetInfo> SpeciesByName = new Dictionary<string, VultureBasedPetInfo>
|
||||
{
|
||||
{ "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } },
|
||||
// Зомбогриф вернулся сюда 2026-09-18 вместе с откатом на летающую ветку. Он снова
|
||||
// EntityVulture, то есть снова целится в игрока захардкоженным C#-кодом, и без этой
|
||||
// строки кидался бы на хозяина. SkipAlreadyCharmedZombies=false: подчинять он не
|
||||
// умеет, значит и обходить подчинённых ему незачем - пусть добивает.
|
||||
//
|
||||
// Честно: 29.08 этот же перехват Грифа НЕ СПАС - он просто летал и никого не
|
||||
// трогал. Тогда причину искать не стали и ушли на наземную ветку. Сейчас мы вернулись
|
||||
// к тому же месту, и если он снова будет безучастно кружить - копать надо здесь,
|
||||
// в том, доходит ли до SetAttackTarget хоть что-нибудь.
|
||||
{ "necroZombieGriffin", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = false } },
|
||||
};
|
||||
|
||||
public static readonly Dictionary<int, VultureBasedPetInfo> cachedClassIds = new Dictionary<int, VultureBasedPetInfo>();
|
||||
|
||||
Reference in New Issue
Block a user