Files
necromants-tome-7d2d-3-2/HarmonySrc/SwarmTargetPatch.cs
T
AlexCubeandClaude Opus 5 d97f83e972 ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале
Работа по указаниям 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>
2026-09-18 20:51:42 +03:00

206 lines
11 KiB
C#

using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// BUG FIXED 2026-08-28 (Insect Swarm attacked the player instead of zombies, even after the
/// entityclasses.xml AITask-2/AITarget-4 fix): wasted effort, because none of that XML
/// mattered. Confirmed by decompiling the actual class chain -
/// necroInsectSwarm -> animalInsectSwarm -> Class="EntitySwarm" -> EntitySwarm : EntityVulture.
/// EntityVulture does NOT use the generic AITask/AITarget system for target selection at all -
/// it has its own hardcoded C# targeting (updateTasks()'s State.Wander branch calls
/// FindTarget(), which calls World.GetClosestPlayerSeen/GetClosestPlayer - literally typed to
/// return EntityPlayer, there is no "closest zombie" variant to point it at). Every ground
/// creature in this mod (the Dog, all vanilla animals) goes through the declarative AITask
/// system just fine; flying "swarm" creatures (insect swarm, bee swarm, vultures) are a
/// completely separate hardcoded-C# codepath. No XML property changes that.
///
/// Fix: Prefix on EntityAlive.SetAttackTarget - the one non-EntityPlayer-typed choke point
/// every one of EntityVulture's several call sites funnels through (FindTarget() results,
/// revenge-target retaliation, sleeper wake-up - all of them end in a SetAttackTarget call,
/// confirmed by decompiling EntityVulture). Whenever the entity is one of OUR
/// EntityVulture-based pets AND the target it's about to be given is an EntityPlayer, swap in
/// the nearest EntityZombie instead (or null if none are nearby - just idles, better than
/// attacking the player). Only filters on our own entity classes, so vanilla's own
/// animalInsectSwarm/animalBeeSwarm/real vultures are entirely unaffected and keep hunting
/// players normally.
///
/// GENERALIZED 2026-08-29, THEN UN-GENERALIZED SAME DAY: briefly also covered
/// necroZombieGriffin (extending animalZombieVulture, the same Class="EntityVulture" root as
/// the Swarm) via this same redirect. Confirmed live in-game that this didn't actually fix
/// the Griffin - it just flew around doing EntityVulture's own default Wander behavior,
/// never engaging zombies at all ("летает где-то в небе, и зомби его вообще не интересуют").
/// Rather than keep debugging the redirect blind (each guess needs a full test cycle the
/// user has to run), the Griffin was converted to extend necroZombieDog directly instead (see
/// entityclasses.xml) - same proven-reliable ground-AI trick as the Bear/Wolf, no longer
/// EntityVulture-based at all, so it no longer needs this patch. Kept the Dictionary-based
/// shape below (rather than reverting to a single cached id) in case a genuinely flying pet
/// gets added again later - SpeciesByName just has one entry for now.
/// </summary>
[HarmonyPatch(typeof(EntityAlive), "SetAttackTarget", new System.Type[] { typeof(EntityAlive), typeof(int) })]
public static class Patch_EntityAlive_SetAttackTarget_SwarmRetarget
{
public class VultureBasedPetInfo
{
public bool SkipAlreadyCharmedZombies;
}
/// <summary>HARDENED 2026-08-29 while chasing the user's "Griffin still attacks me"
/// report - the Griffin's own AI is not XML-driven at all (see class comment), so this
/// Harmony redirect not firing was the prime remaining suspect. Could not fully confirm
/// or rule this out by decompilation alone, but the ORIGINAL lazy-cache pattern here had
/// two real, independent failure modes worth closing regardless of which (if either) was
/// the actual cause: (1) EntityClass.GetId("necroInsectSwarm") and
/// EntityClass.GetId("necroZombieGriffin") were both looked up inside ONE dictionary
/// object-initializer - if EITHER happened to still return -1 (not yet registered) at
/// the exact moment some entirely unrelated zombie's very first SetAttackTarget call
/// triggered this lazy build (plausible - that can happen extremely early, before every
/// mod entity_class is guaranteed loaded), the -1 got cached FOREVER via the
/// cachedClassIds==null guard, silently never re-resolving even once the real class WAS
/// registered a moment later - and if BOTH happened to be -1 at once, the dictionary
/// initializer would throw (duplicate key), which could break unrelated zombie AI too.
/// Rewritten to resolve each species independently and only cache a REAL (non -1) id -
/// an unresolved species is retried on every subsequent call instead of being poisoned
/// permanently, and two entries can never collide on a shared -1 key.</summary>
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>();
public static Dictionary<int, VultureBasedPetInfo> ClassIds()
{
// Fast path once every species has resolved (the overwhelmingly common case, since
// this runs on EntityAlive.SetAttackTarget - a hot path called for every zombie in
// the game, not just ours) - skips the resolution loop below entirely instead of
// re-scanning it on every single call.
if (cachedClassIds.Count >= SpeciesByName.Count)
{
return cachedClassIds;
}
foreach (KeyValuePair<string, VultureBasedPetInfo> species in SpeciesByName)
{
bool alreadyCached = false;
foreach (KeyValuePair<int, VultureBasedPetInfo> cached in cachedClassIds)
{
if (cached.Value == species.Value)
{
alreadyCached = true;
break;
}
}
if (alreadyCached)
{
continue;
}
int id = EntityClass.GetId(species.Key);
if (id == -1)
{
continue;
}
cachedClassIds[id] = species.Value;
}
return cachedClassIds;
}
/// <summary>Kept separate from ClassIds() above (which is about retargeting, shared by
/// both pets) - this one is Swarm-ONLY, used by PetFollowPatch.cs's "drop an
/// already-charmed target so it moves on" behavior, which only makes sense for a species
/// that actually charms zombies (the Griffin doesn't). Same retry-until-resolved shape as
/// ClassIds() above, for the same reason - never cache a -1.</summary>
public static int cachedSwarmOnlyClassId = -1;
public static int SwarmOnlyClassId()
{
if (cachedSwarmOnlyClassId == -1)
{
cachedSwarmOnlyClassId = EntityClass.GetId("necroInsectSwarm");
}
return cachedSwarmOnlyClassId;
}
public static void Prefix(EntityAlive __instance, ref EntityAlive _attackTarget)
{
if (__instance == null || !(_attackTarget is EntityPlayer))
{
return;
}
if (!ClassIds().TryGetValue(__instance.entityClass, out VultureBasedPetInfo petInfo))
{
return;
}
EntityAlive nearestZombie = FindNearestZombie(__instance, petInfo.SkipAlreadyCharmedZombies);
Debug.Log("[NecromancerTome] SwarmTargetPatch: redirected " + __instance.entityId + " from player " + _attackTarget.entityId + " to " + (nearestZombie != null ? nearestZombie.entityId.ToString() : "nothing nearby"));
_attackTarget = nearestZombie;
}
/// <summary>Same World.GetEntitiesInBounds(Type, Bounds, List&lt;Entity&gt;) API
/// EntityVulture itself uses for its own player search (confirmed by decompiling it) -
/// just pointed at EntityZombie instead of EntityPlayer. 80m box, matching FindTarget's
/// own cTargetDistanceMax constant, for "ищут всех зомби в радиусе".
///
/// BUG FIXED 2026-08-28 ("покусав одного, летят куда-то далеко, вместо соседнего
/// незаражённого"): this didn't skip already-charmed zombies, so when
/// PetFollowPatch.cs's "drop an already-charmed target" cleared the swarm's target, the
/// very next FindTarget()->SetAttackTarget cycle would often just re-pick the SAME
/// zombie it had just charmed (still the physically nearest one right after biting it) -
/// PetFollowPatch would clear it again next tick, and in between, EntityVulture (a
/// flying creature) fell into its own Wander state, which for a flier means big aerial
/// loops away from its current spot, not calm circling. A second, genuinely uncharmed
/// zombie standing right next to the first one would lose out to this loop instead of
/// being picked immediately. Now skips any zombie that already carries
/// buffNecroDeviatorCharm - the real "next AND uncharmed" search the user asked for. Only
/// falls through to wide wandering when there truly isn't one nearby, same as before.
///
/// <paramref name="skipAlreadyCharmed"/> added 2026-08-29 alongside the Griffin
/// generalization above - true for the Swarm (its own charm-on-bite behavior, unchanged),
/// false for the Griffin (a plain fighter with no reason to avoid already-charmed
/// zombies).</summary>
public static EntityAlive FindNearestZombie(EntityAlive swarm, bool skipAlreadyCharmed)
{
World world = swarm.world;
if (world == null)
{
return null;
}
List<Entity> nearby = new List<Entity>();
Bounds bounds = new Bounds(swarm.position, new Vector3(80f, 80f, 80f));
world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
EntityAlive nearest = null;
float bestDistSq = float.MaxValue;
foreach (Entity entity in nearby)
{
if (!(entity is EntityAlive zombie) || zombie.IsDead())
{
continue;
}
if (skipAlreadyCharmed && zombie.Buffs != null && zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
{
continue;
}
float distSq = (zombie.position - swarm.position).sqrMagnitude;
if (distSq < bestDistSq)
{
bestDistSq = distSq;
nearest = zombie;
}
}
return nearest;
}
}
}