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

Работа по указаниям 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
+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);
}
}
}
}