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

Работа по указаниям 2026-09-18. В релиз пока не выходит.

НОВЫЙ ПИТОМЕЦ "Дух крысы" (necroRatSpirit), Некромантия 3 (60 убийств):
модель зомбоволка в масштабе 0.28, урон 5, кроличьи звуки. Сам не нападает
никогда, держится справа-сзади в двух блоках, упёршись в препятствие проходит
СКВОЗЬ него и блоков не ломает. Укус замедляет, оставляет метку духа (видна на
компасе, +25% получаемого урона) и рвёт жилу. Обгрызая труп, лечится.

СВОЯ ЗАДАЧА ИИ. Задачи "иди за сущностью" в игре нет вовсе - проверены все 32
типа EAI*. Написана NecroFollowOwnerTask по образцу EAIApproachSpot: сектор
"3-6 часов" от хозяина, FindPath с canBreak:false, проход сквозь препятствие
через Entity.IsStuck. Вешается в рантайме, минуя Type.GetType.

ПРИКАЗ АТАКОВАТЬ. Повторное применение свитка при живом питомце натравливает
его на цель под прицелом: EntityPlayerLocal.HitInfo + ItemActionAttack.
GetEntityFromHit. Торговцы и игроки отсеяны. Нет цели - "Нет цели для атаки".

ЗОМБОЖИВОТНЫЕ ПРИВЕДЕНЫ К ТОМУ ЖЕ ОБРАЗЦУ. Сняты BreakBlock, Territorial,
ApproachSpot, Wander, BlockingTargetTask и поедание трупов; цель они больше не
выбирают сами. Лестница урона 20/35/45/60 плюс расчленение у Пса, Медведя и
Волка. Кровотечение всем, метка и ослабление - только у крысы.

УБИЙСТВА ПИТОМЦЕМ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ, включая добивание кровотечением.
Префикс на AwardKillXPServer подменяет убийцу владельцем; для смерти от баффа
заведена память укусов, потому что в DamageSource от баффа нет того, кто его
наложил. Зомби под Камнем духов это не задело - решение от 17.09 в силе.

ГРИФ откачен на летающую ветку EntityVulture и переименован в Могильного
стервятника: модель наконец соответствует имени. Держится у игрока сам, через
собственный механизм "дома" (setHomeArea), на время погони дом отвязывается.
Попытка натянуть птичий префаб на наземный класс провалилась и записана - так
делать нельзя.

ПРОЧЕЕ: призрачный вид распространён с торговцев на питомцев (у Пса, Медведя и
Волка выключен по указанию), у Пса светятся фиолетовые глаза, белая иконка
книги снята со всех свитков призыва, "Жуки Властелина" переименованы в "Рой
фараона" на всех 13 языках.

ПОРОГИ: крыса 60, стервятник 500, пёс 1300, медведь 2000, волк 4000.

ИСПРАВЛЕНО ПО ХОДУ ИГРОВЫХ ПРОВЕРОК: питомцы не призывались обычным кликом
(AnimWait требовал удержания), отзыв срабатывал не с первого раза (автомат
состояний ItemActionSpawnEntity), крыса проваливалась сквозь мир (IsStuck
отключает и пол), питомец подбрасывал хозяина (коллайдеры разводились до
появления модели).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-09-18 20:51:42 +03:00
co-authored by Claude Opus 5
parent 15c59dd37a
commit d97f83e972
21 changed files with 2501 additions and 179 deletions
+77 -7
View File
@@ -266,6 +266,61 @@ namespace NecromancerTome
timer = 0f;
}
/// <summary>Кого этот проход обращает в призрака.
///
/// РАСШИРЕНО 2026-09-18 С ТОРГОВЦЕВ НА ПИТОМЦЕВ, по указанию: "всех призванных существ
/// (зомбопёс, зомбомедведь, зомбоволк, зомбогриф) тоже делаем полупрозрачными как сейчас
/// торговцев". Плюс Дух крысы, который призраком и задуман.
///
/// Обобщение вышло механическим, и это не совпадение: ApplyGreyscale ниже никогда не
/// пользовался ничем трейдерским - только GetComponentsInChildren&lt;Renderer&gt;(),
/// 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);
+92
View File
@@ -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;
}
}
}
+235
View File
@@ -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;
}
}
}
+299 -2
View File
@@ -53,6 +53,27 @@ namespace NecromancerTome
{
public int OwnerEntityId;
public int PetEntityId;
/// <summary>У питомца своя задача следования (NecroFollowOwnerTask, 2026-09-18) -
/// значит телепорт-поводок ниже его НЕ трогает. Уборка владения при этом остаётся:
/// она к способу передвижения отношения не имеет.</summary>
public bool OwnFollowTask;
/// <summary>Удалось ли развести коллайдеры питомца с хозяйскими. Пока false, попытка
/// повторяется каждый тик - см. TryIgnoreCollisionWithOwner.</summary>
public bool CollisionIgnored;
/// <summary>Зажжены ли глаза (PetEyeGlowPatch.cs). Как и коллайдеры, с первого раза
/// обычно не получается - модели ещё нет; попытка повторяется до успеха.</summary>
public bool EyesLit;
/// <summary>Нужно ли им вообще заниматься: копия PetInfo.LitEyes, чтобы тик не лазил
/// в словарь на каждом питомце каждую секунду.</summary>
public bool WantsLitEyes;
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
/// питомец не летающий, дом ему не переставляем.</summary>
public int FlyingHomeRadius;
}
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
@@ -62,12 +83,98 @@ namespace NecromancerTome
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
}
/// <summary>ДОБАВЛЕНО 2026-09-18. TrackedPets - статический список, он переживает выход в
/// меню, а вот мир, к которому относятся лежащие в нём entityId, - нет. Без этой уборки
/// на следующей загрузке первый же тик начинал разбирать чужие идентификаторы: в новом
/// мире тот же номер принадлежит совершенно другой сущности, и RemoveOwnedEntity уходил
/// бы неизвестно куда. GhostTraderPatch.cs подписан на то же событие ровно по той же
/// причине; здесь подписки не было - это был найденный, но не закрытый пробел из разбора
/// ИИ питомцев (BACKLOG.md, 2026-09-18, п. 6).</summary>
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
{
if (TrackedPets.Count > 0)
{
Debug.Log("[NecromancerTome] PetFollowPatch: world shutting down, forgetting " +
TrackedPets.Count + " tracked pet(s)");
TrackedPets.Clear();
}
timer = 0f;
}
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
public static void Register(EntityAlive owner, Entity pet)
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
bool wantsLitEyes, int flyingHomeRadius)
{
TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId });
TrackedPets.Add(new TrackedPet
{
OwnerEntityId = owner.entityId,
PetEntityId = pet.entityId,
OwnFollowTask = ownFollowTask,
CollisionIgnored = collisionIgnored,
WantsLitEyes = wantsLitEyes,
FlyingHomeRadius = flyingHomeRadius,
});
}
/// <summary>Разводит коллайдеры питомца и его хозяина, чтобы они проходили друг сквозь
/// друга. Возвращает true, только если РЕАЛЬНО что-то развела.
///
/// БАГ-РЕПОРТ 2026-09-18: "когда крыса оказывается под ногами, игрока подбрасывает".
/// Просьба сделать питомца нематериальным для игрока была ещё 28.08, и код для неё
/// написан тогда же (SummonPatch.IgnoreCollisionWithOwner) - но он вызывался ровно один
/// раз, из постфикса на EntityFactory.CreateEntity, а это СЛИШКОМ РАНО: там сущность
/// только создана и в мир ещё не добавлена (SpawnEntityInWorld идёт следующей строкой в
/// ItemActionSpawnEntity.Spawn), модель не собрана, и коллайдеров, привязанных к костям
/// через PhysicsBody, попросту ещё нет. GetComponentsInChildren возвращал пустой массив,
/// цикл не делал ни одной итерации, и никто этого не замечал - метод ничего не возвращал
/// и ничего не логировал. Ровно тот же урок про "модель собирается позже" уже записан в
/// GhostTraderPatch.cs, где из-за него пришлось опрашивать торговцев по таймеру.
///
/// Поэтому теперь попытка повторяется в тике раз в секунду, пока не удастся. Толкать
/// игрока питомец сможет в худшем случае одну секунду после призыва.
///
/// Разводятся ВСЕ пары коллайдеров, включая капсулы передвижения: CharacterController -
/// это тоже Collider, и именно на ней игрок и стоит, когда его подбрасывает.</summary>
public static bool TryIgnoreCollisionWithOwner(EntityAlive owner, Entity pet)
{
if (owner == null || pet == null)
{
return false;
}
Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>(true);
Collider[] petColliders = pet.GetComponentsInChildren<Collider>(true);
if (ownerColliders == null || petColliders == null ||
ownerColliders.Length == 0 || petColliders.Length == 0)
{
return false;
}
int pairs = 0;
foreach (Collider oc in ownerColliders)
{
if (oc == null)
{
continue;
}
foreach (Collider pc in petColliders)
{
if (pc == null || pc == oc)
{
continue;
}
Physics.IgnoreCollision(oc, pc, true);
pairs++;
}
}
if (pairs > 0)
{
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " passes through owner " +
owner.entityId + " now (" + ownerColliders.Length + "x" + petColliders.Length +
" collider pairs ignored)");
}
return pairs > 0;
}
/// <summary>Called from SummonPatch.cs's manual recall path so a recalled pet stops being
@@ -83,6 +190,130 @@ namespace NecromancerTome
}
}
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
///
/// Поедание тут не отдельное поведение, а побочный эффект приказа: команда атаковать ставит
/// цель на 6000 тиков (PetAttackCommand.OrderTicks), а EAIApproachAndAttackTarget.CanExecute
/// сверяет только ТИП цели и ничего не знает про её смерть - так что, добив зомби, крыса
/// продолжает грызть труп, пока тот не исчезнет. Само по себе это выглядело хорошо, поэтому
/// не чинится, а используется.
///
/// Лечение идёт отсюда, а не из triggered_effect в items.xml, по простой причине: в XML
/// нельзя спросить "цель мертва". Полный список классов Requirement* в Assembly-CSharp
/// просмотрен - там есть RequirementFullHealth, RequirementHasEntityTag, RequirementNearbyEntities
/// и ещё сорок, но ни одного про смерть цели. Значит гейт всё равно оказался бы в коде, а
/// тик раз в секунду тут уже есть и обходит ровно тех же питомцев.
///
/// Условий три, и все три обязательны: цель есть, цель МЕРТВА (иначе это лечение в бою, а
/// не питание), и крыса рядом с ней (иначе она лечилась бы, стоя в другом конце улицы и
/// только СОБИРАЯСЬ дойти до трупа).</summary>
public const float FeedRangeSq = 2.5f * 2.5f;
/// <summary>Здоровья за секунду поедания. У Духа крысы всего 120 HP, так что 5 - это полное
/// восстановление примерно за полминуты над одним трупом: заметно, но не бесплатно.</summary>
public const int FeedHealthPerSecond = 5;
/// <summary>ДЫРА, ЗАКРЫТАЯ 2026-09-18. Защита от падения живёт в NecroFollowOwnerTask, а
/// та НЕ ИСПОЛНЯЕТСЯ, пока у питомца есть цель (CanExecute возвращает false) - то есть
/// именно в бою, когда питомец и бегает по незнакомым местам, ловить его было нечем.
/// Этот тик работает всегда, поэтому проверка переехала сюда.
///
/// ПРОВЕРКИ ДВЕ, И ОНИ ПРО РАЗНОЕ - это важно не путать.
///
/// 1. АБСОЛЮТНАЯ ВЫСОТА. Ниже отметки 5 не бывает законных причин находиться: мир
/// кончается на нуле, и всё, что туда опустилось, игра удаляет молча
/// (Entity.onUpdate: position.y &lt; 0f -&gt; MarkToUnload). Двусмысленности нет никакой,
/// поэтому спасаем всегда, хоть в бою, хоть нет. Это и есть настоящий детектор
/// падения, и он один закрывает тот случай, на котором 18.09 потерялась крыса.
///
/// 2. НИЖЕ ЦЕЛИ (предложено пользователем). Это детектор НЕ падения, а
/// НЕДОСТИЖИМОСТИ: питомец, который на десять метров ниже того, кого ему велели
/// грызть, скорее всего не упал, а не может добраться - зомби на крыше, на этаже
/// выше, за проломом. Без этой проверки он будет ломиться туда вечно: приказ игрока
/// держится 6000 тиков и сам не истечёт.
///
/// Поэтому здесь цель ещё и СБРАСЫВАЕТСЯ. Вернуть питомца к хозяину, не сняв
/// приказ, значило бы получить маятник: прыжок наверх - бег вниз - прыжок наверх.
///
/// Обратная сторона второй проверки названа честно: зомби, стоящий двумя этажами выше,
/// теперь отменяет приказ вместо бесконечной беготни. По-моему это лучше, но если
/// окажется, что питомец сдаётся слишком рано - крутить UnreachableBelowTarget.</summary>
public const float UnreachableBelowTarget = 10f;
public static void RescueFallen(EntityAlive pet, EntityAlive owner)
{
if (pet == null || owner == null || pet.world == null)
{
return;
}
bool outOfWorld = pet.position.y < NecroFollowOwnerTask.WorldFloorGuard;
EntityAlive target = pet.GetAttackTarget();
bool unreachable = !outOfWorld && target != null && !target.IsDead() &&
pet.position.y < target.position.y - UnreachableBelowTarget;
if (!outOfWorld && !unreachable)
{
return;
}
pet.IsStuck = false;
pet.motion = Vector3.zero;
if (pet.moveHelper != null)
{
pet.moveHelper.Stop();
}
if (unreachable)
{
pet.SetAttackTarget(null, 0);
}
pet.SetPosition(NecroFollowOwnerTask.SlotPosition(owner, 0, pet.world), true);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " rescued to owner " +
owner.entityId + " - " + (outOfWorld
? "выпал из мира (высота " + pet.position.y.ToCultureInvariantString("0.0") + ")"
: "цель недостижима, она выше на " +
(target.position.y - pet.position.y).ToCultureInvariantString("0.0") + " м"));
}
/// <summary>Кому принадлежит этот питомец. null - значит не наш или уже не отслеживается.
/// Нужна PetKillCreditPatch.cs, чтобы зачесть убийство владельцу.</summary>
public static EntityPlayer FindOwnerOfPet(int petEntityId)
{
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null)
{
return null;
}
for (int i = 0; i < TrackedPets.Count; i++)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
return world.GetEntity(TrackedPets[i].OwnerEntityId) as EntityPlayer;
}
}
return null;
}
public static void FeedOnCorpse(EntityAlive pet)
{
EntityAlive target = pet.GetAttackTarget();
if (target == null || !target.IsDead())
{
return;
}
if ((target.position - pet.position).sqrMagnitude > FeedRangeSq)
{
return;
}
int before = pet.Health;
pet.AddHealth(FeedHealthPerSecond);
if (pet.Health != before)
{
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " fed on corpse " +
target.entityId + ", health " + before + " -> " + pet.Health);
}
}
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
timer += Time.deltaTime;
@@ -147,6 +378,72 @@ namespace NecromancerTome
}
}
// Повторная попытка развести коллайдеры - до первого успеха. Стоит ДО всех
// проверок про бой и расстояние: "питомец подбрасывает хозяина" не зависит ни от
// того, ни от другого.
if (!tracked.CollisionIgnored)
{
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
}
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
// пафайндинга, ни своей задачи - см. PetInfo.FlyingHomeRadius.
//
// НА ВРЕМЯ АТАКИ ДОМ ОТВЯЗЫВАЕТСЯ СОВСЕМ (указание того же дня: "для атаки радиус
// нужно увеличить, пусть летит сколько надо... а вот после атаки пусть
// возвращается"). Иначе собственная проверка EntityVulture срывала бы его с цели
// на полпути: она прерывает атаку, как только питомец вышел за радиус.
//
// Отвязка - это detachHome(), то есть maximumHomeDistance = -1, и тогда
// isWithinHomeDistanceCurrentPosition() безусловно возвращает true. Ограничителем
// вместо радиуса остаётся сама дальность приказа: цель назначается только по
// прицелу, а луч прицела не длиннее 30 метров, так что отправить Грифа на другой
// край карты нельзя при всём желании.
//
// Как только цель пропала (убита, недостижима, приказ сброшен), дом на следующей
// же секунде встаёт обратно на хозяина - и питомец возвращается сам.
if (tracked.FlyingHomeRadius > 0)
{
if (pet.GetAttackTarget() != null)
{
if (pet.hasHome())
{
pet.detachHome();
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
" is on a target - home detached for the chase");
}
}
else
{
if (!pet.hasHome())
{
Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId +
" finished the chase - home re-anchored to owner " + owner.entityId);
}
pet.setHomeArea(new Vector3i(owner.position), tracked.FlyingHomeRadius);
}
}
if (tracked.WantsLitEyes && !tracked.EyesLit)
{
PetEyeGlow.DumpMaterialsOnce(pet, EntityClass.list[pet.entityClass]?.entityClassName);
tracked.EyesLit = PetEyeGlow.TryLightEyes(pet);
}
if (tracked.OwnFollowTask)
{
FeedOnCorpse(pet);
RescueFallen(pet, owner);
// У этого питомца есть настоящая задача следования (NecroFollowOwnerTask):
// она сама держит его при хозяине, сама решает, когда пройти сквозь стену, и
// сама сажает его на опорный блок. Телепорт-поводок здесь только мешал бы -
// две системы дёргали бы питомца в разные стороны. Уборка владения выше при
// этом уже отработала, и она остаётся общей для всех.
continue;
}
if (pet.GetAttackTarget() != null)
{
// Mid-fight - let it finish rather than teleporting it away.
+408
View File
@@ -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 &lt; 0f -&gt; MarkToUnload). Виноват IsStuck - он снимает
/// столкновения со ВСЕМ, включая пол, а гравитация никуда не девается: шаг перемещения
/// складывает hitMove с накопленным motion. Четыре секунды прохода над дыркой в полу -
/// и питомца нет.
///
/// Эта проверка работает независимо от прохода и ловит любое падение, а не только наше
/// собственное.</summary>
public const float MaxDropBelowOwner = 6f;
/// <summary>Ниже этой высоты вмешиваемся в любом случае: мир кончается на нуле, и пять
/// метров запаса нужны, чтобы успеть что-то сделать, а не констатировать.</summary>
public const float WorldFloorGuard = 5f;
public int OwnerEntityId = -1;
public Vector3 seekPos;
public int slotIndex;
public float noProgressTime;
public float lastDistSq;
public bool phasing;
public float phaseTime;
public int pathRecalculateTicks;
public override void Init(EntityAlive _theEntity)
{
base.Init(_theEntity);
// 3 - те же биты, что у EAIApproachSpot: это задача ПЕРЕМЕЩЕНИЯ, и она не должна
// исполняться одновременно с другой такой же (в первую очередь с погоней за целью).
MutexBits = 3;
executeDelay = 0.1f;
}
public EntityAlive Owner()
{
if (OwnerEntityId < 0 || theEntity == null || theEntity.world == null)
{
return null;
}
return theEntity.world.GetEntity(OwnerEntityId) as EntityAlive;
}
/// <summary>Точка для слота slotIndex: хозяин + поворот его собственного направления на
/// угол из SlotAngles, посаженный на опорный блок.</summary>
public Vector3 SlotPosition(EntityAlive _owner, int _slot)
{
return SlotPosition(_owner, _slot, theEntity.world, SlotDistance);
}
/// <summary>То же самое, но без экземпляра задачи - нужна PetFollowPatch.cs, который
/// спасает упавших питомцев во время боя, когда сама задача не исполняется. Там точный
/// радиус не важен (питомца просто возвращают к хозяину), поэтому берётся общий.</summary>
public static Vector3 SlotPosition(EntityAlive _owner, int _slot, World _world,
float _slotDistance = DefaultSlotDistance)
{
float angle = SlotAngles[_slot % SlotAngles.Length];
Vector3 dir = _owner.qrotation * (Quaternion.Euler(0f, angle, 0f) * Vector3.forward);
Vector3 raw = _owner.position + dir * _slotDistance;
return _world.FindSupportingBlockPos(raw);
}
public override bool CanExecute()
{
// Дерётся - не мешаем. Цель крысе даёт игрок (PetCommandPatch.cs), и пока она есть,
// место в строю подождёт.
if (theEntity.GetAttackTarget() != null)
{
return false;
}
if (theEntity.IsDead() || theEntity.IsSleeping)
{
return false;
}
EntityAlive owner = Owner();
if (owner == null)
{
return false;
}
seekPos = SlotPosition(owner, slotIndex);
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
}
public override void Start()
{
noProgressTime = 0f;
lastDistSq = float.MaxValue;
phasing = false;
phaseTime = 0f;
pathRecalculateTicks = 0;
}
public override bool Continue()
{
if (theEntity.GetAttackTarget() != null)
{
return false;
}
EntityAlive owner = Owner();
if (owner == null)
{
return false;
}
seekPos = SlotPosition(owner, slotIndex);
return (seekPos - theEntity.position).sqrMagnitude > ArrivedDistSq;
}
public override void Update()
{
EntityAlive owner = Owner();
if (owner == null)
{
return;
}
// ПЕРВОЙ СТРОКОЙ, ДО ВСЕГО ОСТАЛЬНОГО: не падает ли питомец из мира. Проверка стоит
// здесь, а не внутри прохода, намеренно - провалиться можно и без нашего участия
// (дыра в полу POI, взорванный блок), а последствие одно и то же: сущность ниже нуля
// игра удаляет молча, и питомец просто исчезает.
if (theEntity.position.y < WorldFloorGuard ||
theEntity.position.y < owner.position.y - MaxDropBelowOwner)
{
SnapToOwner(owner, "падение (высота " + theEntity.position.y.ToCultureInvariantString("0.0") + ")");
return;
}
Vector3 lookPosition = owner.position;
lookPosition.y += 0.8f;
theEntity.SetLookPosition(lookPosition);
if (phasing)
{
UpdatePhase();
return;
}
float distSq = (seekPos - theEntity.position).sqrMagnitude;
bool ownerFarAway = (owner.position - theEntity.position).sqrMagnitude > LeashDistance * LeashDistance;
// Сокращаем расстояние - значит путь есть и он работает; счётчик простоя сбрасывается.
if (distSq < lastDistSq - 0.05f)
{
noProgressTime = 0f;
}
else
{
noProgressTime += executeDelay;
}
lastDistSq = distSq;
// ХОЗЯИН ДАЛЕКО - ЭТО НЕ ПОВОД ПРОХОДИТЬ СКВОЗЬ СТЕНЫ, и раньше было именно так.
// Проход задуман для препятствия В ШАГЕ, а не для перелёта через тридцать метров
// незнакомой местности с отключёнными столкновениями: ровно такой проход 18.09 и
// уронил крысу под мир. На дальней дистанции работает обычный прыжок поводком - тот
// самый приём, которым ванильный DroneManager возвращает дрона, и он безопасен,
// потому что точка назначения известна заранее и стоит на земле.
if (ownerFarAway)
{
SnapToOwner(owner, "хозяин дальше " + LeashDistance + " м");
return;
}
if (noProgressTime >= NoProgressSeconds)
{
noProgressTime = 0f;
lastDistSq = float.MaxValue;
if (slotIndex >= SlotAngles.Length - 1)
{
// Перепробованы все точки сектора - значит дело в препятствии: сквозь.
slotIndex = 0;
BeginPhase();
return;
}
// Следующая точка сектора - "зависит от препятствий".
slotIndex++;
seekPos = SlotPosition(owner, slotIndex);
pathRecalculateTicks = 0;
}
if (theEntity.navigator.getPath() != null)
{
theEntity.moveHelper.CalcIfUnreachablePos();
}
if (--pathRecalculateTicks <= 0)
{
UpdatePath();
}
}
public void UpdatePath()
{
if (PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
{
return;
}
pathRecalculateTicks = 8 + GetRandom(6);
// canBreak: false - крыса не грызёт блоки, это её главное отличие от всех остальных
// питомцев мода (см. шапку файла).
theEntity.FindPath(seekPos, theEntity.GetMoveSpeedAggro(), false, this);
}
public void BeginPhase()
{
phasing = true;
phaseTime = 0f;
theEntity.moveHelper.Stop();
theEntity.IsStuck = true;
}
public void EndPhase()
{
phasing = false;
phaseTime = 0f;
theEntity.IsStuck = false;
// ОБЯЗАТЕЛЬНО: пока IsStuck был поднят, пола для крысы не существовало. Сажаем её на
// опорный блок, иначе она останется висеть или продолжит падать.
theEntity.SetPosition(theEntity.world.FindSupportingBlockPos(theEntity.position), true);
lastDistSq = float.MaxValue;
noProgressTime = 0f;
pathRecalculateTicks = 0;
}
public void UpdatePhase()
{
phaseTime += executeDelay;
// ГРАВИТАЦИЯ ГАСИТСЯ КАЖДЫЙ ТИК, и это главная правка 18.09. Пока IsStuck поднят,
// шаг перемещения складывает накопленный motion прямо с позицией, минуя контроллер
// и пол, - то есть питомец продолжает "падать" между нашими SetPosition, причём
// ускоряясь. Обнуление motion означает: во время прохода питомца двигает ТОЛЬКО
// этот метод и ничто больше.
theEntity.motion = Vector3.zero;
Vector3 next = Vector3.MoveTowards(theEntity.position, seekPos, PhaseSpeed * executeDelay);
// И отдельно - высота. Даже с обнулённым motion спускаться ниже цели незачем:
// seekPos уже посажен на опорный блок (FindSupportingBlockPos), так что всё, что
// ниже него, - это пол, сквозь который мы проходить не собирались.
if (next.y < seekPos.y)
{
next.y = seekPos.y;
}
theEntity.SetPosition(next, true);
if (phaseTime >= PhaseTimeout || (next - seekPos).sqrMagnitude <= ArrivedDistSq)
{
EndPhase();
}
}
/// <summary>Прыжок к хозяину: аварийный выход и он же дальний поводок. Точка - тот же
/// слот в секторе, посаженный на опорный блок, а не позиция хозяина в упор (иначе
/// коллайдеры сложатся и питомец подбросит игрока - эти грабли уже разобраны в
/// PetFollowPatch.cs).</summary>
public void SnapToOwner(EntityAlive _owner, string _why)
{
if (phasing)
{
phasing = false;
phaseTime = 0f;
}
theEntity.IsStuck = false;
theEntity.motion = Vector3.zero;
theEntity.moveHelper.Stop();
slotIndex = 0;
lastDistSq = float.MaxValue;
noProgressTime = 0f;
pathRecalculateTicks = 0;
Vector3 dest = SlotPosition(_owner, 0);
theEntity.SetPosition(dest, true);
Debug.Log("[NecromancerTome] NecroFollowOwnerTask: pet " + theEntity.entityId +
" snapped back to owner " + _owner.entityId + " - " + _why);
}
public override void Reset()
{
if (phasing)
{
EndPhase();
}
theEntity.IsStuck = false;
theEntity.moveHelper.Stop();
theEntity.SetLookPosition(Vector3.zero);
}
public override string ToString()
{
return string.Format("{0}, slot{1}{2} dist{3}", base.ToString(), slotIndex,
phasing ? " PHASING" : "",
(theEntity.position - seekPos).magnitude.ToCultureInvariantString());
}
}
}
+197
View File
@@ -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 &amp;&amp; !(entityPlayer == this) &amp;&amp; ...)
/// 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
View File
@@ -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 &amp;&amp; --homeCheckDelay &lt;= 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 &amp;&amp; flag15 &amp;&amp; (flag16 || !flag18)) // flag15 = Secondary.IsPressed
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
///
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
///
/// public override bool IsActionRunning(ItemActionData _actionData)
/// =&gt; ((ItemActionDataSpawnEntity)_actionData).state != State.None;
///
/// Состояние проходит None -&gt; Anim -&gt; Spawn -&gt; **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 + ")");
}
}
}
+10
View File
@@ -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>();